diff --git a/.gitignore b/.gitignore index 8252894..84d8e28 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ Backend/obj/ # Local Claude Code config (settings, preview launch configs) .claude/ + +# Local design/prototype exports and audit screenshots +.design-source/ +.od-skills/ +audit/ +*.dc.html +*.dc.html.artifact.json +/frontend/public/*.html +/*-drawing-*.png diff --git a/Backend/.env.example b/Backend/.env.example index 31e6c0a..8a1d581 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -1 +1,11 @@ -VTSA_POSTGRES=Host=localhost;Port=5432;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres +VTSA_POSTGRES=Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only +ConnectionStrings__Postgres=Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only +Frontend__AllowedOrigins__0=http://localhost:5173 +Frontend__AllowedOrigins__1=http://127.0.0.1:5173 +VTSA_SEED_MODE=demo +VTSA_DEMO_LOGIN_ENABLED=true +VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin +VTSA_DEMO_ADMIN_EMAIL=admin@example.local +VTSA_DEMO_ADMIN_PASSWORD=change-me-for-demo +VTSA_DEMO_ADMIN_TWITCH_ID=jayuhime_admin +VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin diff --git a/Backend/Common/ApplicationDefaults.cs b/Backend/Common/ApplicationDefaults.cs new file mode 100644 index 0000000..81f789f --- /dev/null +++ b/Backend/Common/ApplicationDefaults.cs @@ -0,0 +1,16 @@ +namespace Backend.Common; + +public static class ApplicationDefaults +{ + public static readonly string[] FrontendOrigins = + [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173", + ]; + + public const string FrontendCorsPolicy = "frontend"; + public const string AuthRateLimitPolicy = "auth"; + public const string PublicWriteRateLimitPolicy = "public-write"; +} diff --git a/Backend/Common/RequestMetadata.cs b/Backend/Common/RequestMetadata.cs new file mode 100644 index 0000000..5780f4c --- /dev/null +++ b/Backend/Common/RequestMetadata.cs @@ -0,0 +1,3 @@ +namespace Backend.Common; + +public sealed record RequestMetadata(string ClientIp, string UserAgent); diff --git a/Backend/Common/RequestMetadataReader.cs b/Backend/Common/RequestMetadataReader.cs new file mode 100644 index 0000000..d8df4a2 --- /dev/null +++ b/Backend/Common/RequestMetadataReader.cs @@ -0,0 +1,16 @@ +namespace Backend.Common; + +public static class RequestMetadataReader +{ + public static RequestMetadata Read(HttpContext context) => + new(ReadClientIp(context), ReadUserAgent(context)); + + public static string ReadClientIp(HttpContext context) => + context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + public static string ReadUserAgent(HttpContext context) + { + var value = context.Request.Headers.UserAgent.ToString().Trim(); + return value.Length > 400 ? value[..400] : value; + } +} diff --git a/Backend/Common/SeasonMappings.cs b/Backend/Common/SeasonMappings.cs new file mode 100644 index 0000000..cb76243 --- /dev/null +++ b/Backend/Common/SeasonMappings.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using Backend.Contracts; +using Backend.Domain; + +namespace Backend.Common; + +public static class SeasonMappings +{ + public static bool IsSeasonScheduleValid( + DateOnly nominationStartsAt, + DateOnly nominationEndsAt, + DateOnly votingStartsAt, + DateOnly votingEndsAt, + DateOnly reviewStartsAt, + DateOnly reviewEndsAt, + DateOnly showDate) + { + return nominationStartsAt <= nominationEndsAt + && nominationEndsAt <= votingStartsAt + && votingStartsAt <= votingEndsAt + && votingEndsAt <= reviewStartsAt + && reviewStartsAt <= reviewEndsAt + && reviewEndsAt <= showDate; + } + + public static string BuildProfileUrl(string platform, string channelSlug) + { + var normalizedPlatform = platform.Trim().ToLowerInvariant(); + var platformKey = new string(normalizedPlatform.Where(char.IsLetterOrDigit).ToArray()); + var slug = channelSlug.Trim(); + var cleanSlug = slug.TrimStart('@'); + if (slug.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + slug.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return slug; + } + + if (string.IsNullOrWhiteSpace(cleanSlug)) + { + return "#"; + } + + if (string.IsNullOrWhiteSpace(platformKey)) + { + return cleanSlug.Contains('.') ? $"https://{cleanSlug}" : "#"; + } + + return platformKey switch + { + "artstation" => $"https://www.artstation.com/{cleanSlug}", + "bilibili" => $"https://space.bilibili.com/{cleanSlug}", + "bluesky" => $"https://bsky.app/profile/{cleanSlug}", + "booth" => $"https://{cleanSlug}.booth.pm", + "cake" => $"https://cake.gg/@{cleanSlug}", + "discord" => cleanSlug.Contains("discord.", StringComparison.OrdinalIgnoreCase) ? $"https://{cleanSlug}" : $"https://discord.gg/{cleanSlug}", + "deviantart" => $"https://www.deviantart.com/{cleanSlug}", + "facebook" => $"https://facebook.com/{cleanSlug}", + "fanbox" => $"https://{cleanSlug}.fanbox.cc", + "github" => $"https://github.com/{cleanSlug}", + "instagram" => $"https://instagram.com/{cleanSlug}", + "kick" => $"https://kick.com/{cleanSlug}", + "kofi" or "ko-fi" => $"https://ko-fi.com/{cleanSlug}", + "linktree" => $"https://linktr.ee/{cleanSlug}", + "mastodon" => cleanSlug.Contains('@') ? $"https://{cleanSlug.Split('@').Last()}/@{cleanSlug.Split('@').First()}" : $"https://mastodon.social/@{cleanSlug}", + "patreon" => $"https://patreon.com/{cleanSlug}", + "picarto" or "picartotv" => $"https://picarto.tv/{cleanSlug}", + "pinterest" => $"https://pinterest.com/{cleanSlug}", + "pixiv" => $"https://www.pixiv.net/users/{cleanSlug}", + "reddit" => $"https://reddit.com/user/{cleanSlug}", + "skeb" => $"https://skeb.jp/@{cleanSlug}", + "soundcloud" => $"https://soundcloud.com/{cleanSlug}", + "spotify" => $"https://open.spotify.com/user/{cleanSlug}", + "telegram" => $"https://t.me/{cleanSlug}", + "threads" => $"https://threads.net/@{cleanSlug}", + "tiktok" => $"https://tiktok.com/@{cleanSlug}", + "trovo" => $"https://trovo.live/s/{cleanSlug}", + "twitch" => $"https://twitch.tv/{cleanSlug}", + "tumblr" => $"https://{cleanSlug}.tumblr.com", + "vimeo" => $"https://vimeo.com/{cleanSlug}", + "website" or "link" => cleanSlug.Contains('.') ? $"https://{cleanSlug}" : $"https://{cleanSlug}.com", + "youtube" => cleanSlug.StartsWith("@", StringComparison.Ordinal) ? $"https://youtube.com/{cleanSlug}" : $"https://youtube.com/@{cleanSlug}", + "x" or "twitter" => $"https://x.com/{cleanSlug}", + _ => $"https://{platformKey}.com/{cleanSlug}", + }; + } + + public static string NormalizeSeasonStreamUrl(string? value) + { + var trimmed = value?.Trim() ?? string.Empty; + return string.IsNullOrWhiteSpace(trimmed) ? "https://twitch.tv/jayuhime" : trimmed; + } + + public static string NormalizePhaseKey(string? currentPhase) + { + var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty; + if (value.Contains("abgeschlossen") || value.Contains("archiv") || value.Contains("complete") || value.Contains("ended")) + { + return "completed"; + } + + if (value.Contains("show")) + { + return "show"; + } + + if (value.Contains("review") || value.Contains("auswert")) + { + return "review"; + } + + if (value.Contains("vot")) + { + return "voting"; + } + + if (value.Contains("nomin")) + { + return "nomination"; + } + + return "nomination"; + } + + public static string ResolveTimelineState(string itemKey, string currentPhaseKey) + { + string[] phaseOrder = ["nomination", "voting", "review", "show"]; + if (string.Equals(currentPhaseKey, "completed", StringComparison.OrdinalIgnoreCase)) + { + return phaseOrder.Contains(itemKey) ? "done" : "upcoming"; + } + + var itemIndex = Array.IndexOf(phaseOrder, itemKey); + var currentIndex = Array.IndexOf(phaseOrder, currentPhaseKey); + if (itemIndex < 0) + { + return "upcoming"; + } + + if (currentIndex < 0) + { + currentIndex = 0; + } + + if (itemIndex < currentIndex) + { + return "done"; + } + + if (itemIndex == currentIndex) + { + return "active"; + } + + return "upcoming"; + } + + public static T[] DeserializeSiteArray(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize( + json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; + } + catch + { + return []; + } + } + + public static PublicSocialLinkDto[] ReadSocialLinks(SiteSettings settings) => + DeserializeSiteArray(settings.SocialLinksJson); + + public static FaqItemDto[] ReadFaqItems(SiteSettings settings) => + DeserializeSiteArray(settings.FaqJson); + + public static FooterLinkDto[] BuildFooterLinks(SiteSettings settings) => + [ + new FooterLinkDto("Impressum", settings.ImprintUrl), + new FooterLinkDto("Kontakt", settings.ContactUrl), + new FooterLinkDto("Sponsoren & Partner", settings.SponsorsUrl), + ]; +} diff --git a/Backend/Configuration/FrontendOptions.cs b/Backend/Configuration/FrontendOptions.cs new file mode 100644 index 0000000..f676e67 --- /dev/null +++ b/Backend/Configuration/FrontendOptions.cs @@ -0,0 +1,8 @@ +namespace Backend.Configuration; + +public sealed class FrontendOptions +{ + public const string SectionName = "Frontend"; + + public string[] AllowedOrigins { get; init; } = []; +} diff --git a/Backend/Contracts/AdminContracts.cs b/Backend/Contracts/AdminContracts.cs deleted file mode 100644 index 8483cea..0000000 --- a/Backend/Contracts/AdminContracts.cs +++ /dev/null @@ -1,115 +0,0 @@ -namespace Backend.Contracts; - -public sealed record AdminMetricDto(string Label, int Value, string Note); - -public sealed record AdminActivityDto(string Label, string Age); - -public sealed record AdminTopCategoryDto(string Category, int Votes); - -public sealed record AdminRiskFlagDto( - int Id, - string Source, - string Type, - string Severity, - string Status, - string Summary, - string? TwitchUserId, - string CreatedFromIp, - DateTimeOffset CreatedAt, - string MetadataJson); - -public sealed record AdminAuditEntryDto( - int Id, - string AdminTwitchUserId, - string ActionType, - string EntityType, - string EntityId, - string Summary, - DateTimeOffset CreatedAt); - -public sealed record AdminDashboardResponse( - IEnumerable Metrics, - IEnumerable Activities, - IEnumerable TopCategories, - IEnumerable RiskFlags, - IEnumerable AuditEntries); - -public sealed record AdminSeasonListItemDto( - int Id, - int Year, - string Name, - string CurrentPhase, - bool IsCurrent, - int CategoryCount); - -public sealed record AdminCategoryItemDto( - int Id, - string GroupName, - string Name, - string Slug, - string Description, - int SortOrder, - int MaxNomineesPerUser, - int CandidateCount); - -public sealed record AdminCandidateItemDto( - int Id, - int CategoryId, - string DisplayName, - string ChannelSlug, - string Platform); - -public sealed record AdminNominationReviewItemDto( - int Id, - int CategoryId, - string CategoryName, - string SubmittedByTwitchId, - string CandidateText, - DateTimeOffset CreatedAt); - -public sealed record AdminClipSubmissionItemDto( - int Id, - int? CategoryId, - string SubmittedByTwitchId, - string ClipUrl, - string Title, - string Creator, - string Platform, - string Status, - DateTimeOffset CreatedAt); - -public sealed record AdminSeasonDetailResponse( - int Id, - int Year, - string Name, - string CurrentPhase, - bool IsCurrent, - IEnumerable Categories, - IEnumerable Candidates, - IEnumerable PendingNominations, - IEnumerable ClipSubmissions); - -public sealed record UpdateSeasonRequest( - string CurrentPhase, - bool IsCurrent); - -public sealed record UpsertCategoryRequest( - string GroupName, - string Name, - string Slug, - string Description, - int SortOrder, - int MaxNomineesPerUser); - -public sealed record UpsertCandidateRequest( - int CategoryId, - string DisplayName, - string ChannelSlug, - string Platform); - -public sealed record ApproveNominationRequest( - string? DisplayName, - string? ChannelSlug, - string? Platform); - -public sealed record ResolveRiskFlagRequest(string Status); diff --git a/Backend/Contracts/AdminDashboardContracts.cs b/Backend/Contracts/AdminDashboardContracts.cs new file mode 100644 index 0000000..899434a --- /dev/null +++ b/Backend/Contracts/AdminDashboardContracts.cs @@ -0,0 +1,14 @@ +namespace Backend.Contracts; + +public sealed record AdminMetricDto(string Label, int Value, string Note); + +public sealed record AdminActivityDto(string Label, string Age); + +public sealed record AdminTopCategoryDto(string Category, int Votes); + +public sealed record AdminDashboardResponse( + IEnumerable Metrics, + IEnumerable Activities, + IEnumerable TopCategories, + IEnumerable RiskFlags, + IEnumerable AuditEntries); diff --git a/Backend/Contracts/AdminModerationContracts.cs b/Backend/Contracts/AdminModerationContracts.cs new file mode 100644 index 0000000..fcfbc16 --- /dev/null +++ b/Backend/Contracts/AdminModerationContracts.cs @@ -0,0 +1,118 @@ +namespace Backend.Contracts; + +public sealed record AdminRiskFlagDto( + int Id, + string Source, + string Type, + string Severity, + string Status, + string Summary, + string? TwitchUserId, + string CreatedFromIp, + DateTimeOffset CreatedAt, + string MetadataJson, + string? ReviewNote, + string? ReviewedByTwitchId, + DateTimeOffset? ReviewedAt, + AdminRiskEntityLinkDto[] EntityLinks); + +public sealed record AdminRiskEntityLinkDto( + string Label, + string EntityType, + string EntityId, + string To); + +public sealed record AdminRiskCountDto(string Key, int Count); + +public sealed record AdminRiskFlagsResponse( + AdminRiskFlagDto[] Items, + int TotalCount, + int ReturnedCount, + int Offset, + int Limit, + bool HasMore, + AdminRiskCountDto[] SeverityCounts, + AdminRiskCountDto[] StatusCounts); + +public sealed record AdminAuditEntryDto( + int Id, + string AdminTwitchUserId, + string ActionType, + string EntityType, + string EntityId, + string Summary, + DateTimeOffset CreatedAt, + string MetadataJson, + string CreatedFromIp, + string UserAgent); + +public sealed record AdminAuditEntriesResponse( + AdminAuditEntryDto[] Items, + int TotalCount, + int ReturnedCount, + string? NextCursor, + int Limit); + +public sealed record AdminNominationReviewItemDto( + int Id, + int CategoryId, + string CategoryName, + string SubmittedByTwitchId, + string CandidateText, + string? StreamUrl, + string Status, + DateTimeOffset CreatedAt, + int? CandidateId, + string? CandidateDisplayName, + string? ReviewNote, + string? ReviewedByTwitchId, + DateTimeOffset? ReviewedAt); + +public sealed record AdminClipSubmissionItemDto( + int Id, + int? CategoryId, + int? CandidateId, + string SubmittedByTwitchId, + string ClipUrl, + string Title, + string Creator, + string Platform, + string Status, + DateTimeOffset CreatedAt, + string? ReviewNote, + string? ReviewedByTwitchId, + DateTimeOffset? ReviewedAt); + +public sealed record ApproveNominationRequest( + string? DisplayName, + string? ChannelSlug, + string? Platform, + string? ReviewNote); + +public sealed record RejectNominationRequest(string? ReviewNote); + +public sealed record UpdateClipStatusRequest( + string Status, + string? ReviewNote); + +public sealed record ResolveRiskFlagRequest( + string Status, + string? ReviewNote); + +public sealed record BulkResolveRiskFlagsRequest( + int[] RiskFlagIds, + string Status, + string? ReviewNote); + +public sealed record AdminRiskRuleDto( + string Key, + string Label, + bool Enabled, + int Threshold, + int WindowMinutes, + string Severity, + string Description); + +public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules); + +public sealed record UpdateRiskRulesRequest(AdminRiskRuleDto[] Rules); diff --git a/Backend/Contracts/AdminSeasonContracts.cs b/Backend/Contracts/AdminSeasonContracts.cs new file mode 100644 index 0000000..fb6e06a --- /dev/null +++ b/Backend/Contracts/AdminSeasonContracts.cs @@ -0,0 +1,109 @@ +namespace Backend.Contracts; + +public sealed record AdminSeasonListItemDto( + int Id, + int Year, + string Name, + string CurrentPhase, + bool IsCurrent, + int CategoryCount); + +public sealed record AdminCategoryItemDto( + int Id, + string GroupName, + string Name, + string Slug, + string Description, + int SortOrder, + int MaxNomineesPerUser, + int CandidateCount); + +public sealed record AdminCandidateItemDto( + int Id, + int CategoryId, + string DisplayName, + string ChannelSlug, + string Platform); + +public sealed record AdminAwardResultItemDto( + int Id, + int CategoryId, + string CategoryName, + int CandidateId, + string CandidateDisplayName, + string CandidateChannelSlug, + string CandidatePlatform); + +public sealed record AdminSeasonDetailResponse( + int Id, + int Year, + string Name, + string ShowStreamUrl, + string CurrentPhase, + bool IsCurrent, + bool IsCommunityOnly, + DateOnly NominationStartsAt, + DateOnly NominationEndsAt, + DateOnly VotingStartsAt, + DateOnly VotingEndsAt, + DateOnly ReviewStartsAt, + DateOnly ReviewEndsAt, + DateOnly ShowDate, + TimeOnly ShowStartsAt, + IEnumerable Categories, + IEnumerable Candidates, + IEnumerable PendingNominations, + IEnumerable ReviewedNominations, + IEnumerable Results, + IEnumerable ClipSubmissions); + +public sealed record CreateSeasonRequest( + int Year, + string Name, + string ShowStreamUrl, + string CurrentPhase, + bool IsCurrent, + bool IsCommunityOnly, + DateOnly NominationStartsAt, + DateOnly NominationEndsAt, + DateOnly VotingStartsAt, + DateOnly VotingEndsAt, + DateOnly ReviewStartsAt, + DateOnly ReviewEndsAt, + DateOnly ShowDate, + TimeOnly ShowStartsAt, + int? CopyStructureFromSeasonId = null); + +public sealed record UpdateSeasonRequest( + int Year, + string Name, + string ShowStreamUrl, + string CurrentPhase, + bool IsCurrent, + bool IsCommunityOnly, + DateOnly NominationStartsAt, + DateOnly NominationEndsAt, + DateOnly VotingStartsAt, + DateOnly VotingEndsAt, + DateOnly ReviewStartsAt, + DateOnly ReviewEndsAt, + DateOnly ShowDate, + TimeOnly ShowStartsAt); + +public sealed record UpsertCategoryRequest( + string GroupName, + string Name, + string Slug, + string Description, + int SortOrder, + int MaxNomineesPerUser); + +public sealed record UpsertCandidateRequest( + int CategoryId, + string DisplayName, + string ChannelSlug, + string Platform); + +public sealed record SetAwardResultRequest( + int CategoryId, + int CandidateId); diff --git a/Backend/Contracts/AdminSiteSettingsContracts.cs b/Backend/Contracts/AdminSiteSettingsContracts.cs new file mode 100644 index 0000000..cf996e3 --- /dev/null +++ b/Backend/Contracts/AdminSiteSettingsContracts.cs @@ -0,0 +1,48 @@ +namespace Backend.Contracts; + +public sealed record AdminSiteSettingsResponse( + string HostDisplayName, + string HostTagline, + string NewsletterUrl, + string PrivacyEmail, + string PrivacyPolicyContent, + string? PrivacyPolicyUpdatedBy, + DateTimeOffset? PrivacyPolicyUpdatedAt, + string ImprintUrl, + string ContactUrl, + string SponsorsUrl, + IEnumerable SocialLinks, + IEnumerable Faq); + +public sealed record UpdateSiteSettingsRequest( + string HostDisplayName, + string HostTagline, + string NewsletterUrl, + string PrivacyEmail, + string PrivacyPolicyContent, + string ImprintUrl, + string ContactUrl, + string SponsorsUrl, + PublicSocialLinkDto[] SocialLinks, + FaqItemDto[] Faq); + +public sealed record AdminOperationalSettingsResponse( + bool DemoLoginManagedByDatabase, + bool DemoLoginEnabled, + string DemoLoginEmail, + bool DemoLoginPasswordSet, + string DemoLoginTwitchUserId, + string DemoLoginDisplayName, + bool MaintenanceModeEnabled, + string MaintenanceTitle, + string MaintenanceMessage); + +public sealed record UpdateOperationalSettingsRequest( + bool DemoLoginEnabled, + string DemoLoginEmail, + string? DemoLoginPassword, + string DemoLoginTwitchUserId, + string DemoLoginDisplayName, + bool MaintenanceModeEnabled, + string MaintenanceTitle, + string MaintenanceMessage); diff --git a/Backend/Contracts/AuthContracts.cs b/Backend/Contracts/AuthContracts.cs index 8fe48ec..ec7e472 100644 --- a/Backend/Contracts/AuthContracts.cs +++ b/Backend/Contracts/AuthContracts.cs @@ -5,6 +5,11 @@ public sealed record LoginRequest( string DisplayName, string Role); +public sealed record DemoLoginRequest( + string? Login, + string? Email, + string? Password); + public sealed record AuthSessionDto( string SessionToken, string TwitchUserId, diff --git a/Backend/Contracts/PublicContracts.cs b/Backend/Contracts/PublicContracts.cs deleted file mode 100644 index dd2b8a9..0000000 --- a/Backend/Contracts/PublicContracts.cs +++ /dev/null @@ -1,87 +0,0 @@ -namespace Backend.Contracts; - -public sealed record TimelineItem( - string Key, - string Title, - DateOnly StartsAt, - DateOnly EndsAt, - string State); - -public sealed record FeaturedCategoryDto( - int Id, - string GroupName, - string Name, - string Description, - int MaxNomineesPerUser); - -public sealed record WinnerPreviewDto( - int Year, - string Category, - string WinnerName, - string WinnerSlug); - -public sealed record FaqItemDto(string Question, string Answer); - -public sealed record OverviewResponse( - int SeasonId, - int Year, - string Title, - DateOnly ShowDate, - string CurrentPhase, - bool IsCommunityOnly, - string LoginProvider, - IEnumerable Timeline, - IEnumerable FeaturedCategories, - IEnumerable WinnersPreview, - IEnumerable Faq); - -public sealed record CandidateSummaryDto( - int Id, - string DisplayName, - string ChannelSlug, - string Platform); - -public sealed record PublicCategoryDetailDto( - int Id, - string Name, - string GroupName, - string Description, - int MaxNomineesPerUser, - IEnumerable Candidates); - -public sealed record SeasonCategoriesResponse( - int SeasonId, - int Year, - IEnumerable Categories); - -public sealed record WinnerArchiveItemDto( - string Category, - string WinnerName, - string WinnerSlug); - -public sealed record WinnerArchiveResponse( - int Year, - IEnumerable Items); - -public sealed record CreateNominationRequest( - int Year, - int CategoryId, - string TwitchUserId, - string[] Nominees); - -public sealed record VoteEntryRequest( - int CategoryId, - int CandidateId); - -public sealed record CreateVoteRequest( - int SeasonId, - string TwitchUserId, - VoteEntryRequest[] Entries); - -public sealed record CreateClipRequest( - int Year, - int? CategoryId, - string TwitchUserId, - string ClipUrl, - string Title, - string Creator); diff --git a/Backend/Contracts/PublicOverviewContracts.cs b/Backend/Contracts/PublicOverviewContracts.cs new file mode 100644 index 0000000..2947b0e --- /dev/null +++ b/Backend/Contracts/PublicOverviewContracts.cs @@ -0,0 +1,68 @@ +namespace Backend.Contracts; + +public sealed record TimelineItem( + string Key, + string Title, + DateOnly StartsAt, + DateOnly EndsAt, + string State); + +public sealed record FeaturedCategoryDto( + int Id, + string GroupName, + string Name, + string Description, + int MaxNomineesPerUser); + +public sealed record WinnerPreviewDto( + int Year, + string Category, + string WinnerName, + string WinnerSlug, + string WinnerPlatform, + string WinnerUrl); + +public sealed record FaqItemDto(string Question, string Answer); + +public sealed record PublicSocialLinkDto( + string Label, + string Platform, + string Url, + string? Icon = null, + bool ShowOnHost = true, + bool ShowOnCommunity = true); + +public sealed record FooterLinkDto( + string Label, + string Url); + +public sealed record PublicSiteContentDto( + string HostDisplayName, + string HostTagline, + string NewsletterUrl, + string PrivacyEmail, + string PrivacyPolicyContent, + IEnumerable SocialLinks, + IEnumerable FooterLinks); + +public sealed record PublicSiteStatusResponse( + bool DemoLoginEnabled, + bool MaintenanceModeEnabled, + string MaintenanceTitle, + string MaintenanceMessage); + +public sealed record OverviewResponse( + int SeasonId, + int Year, + string Title, + DateOnly ShowDate, + TimeOnly ShowStartsAt, + string ShowStreamUrl, + string CurrentPhase, + bool IsCommunityOnly, + string LoginProvider, + IEnumerable Timeline, + IEnumerable FeaturedCategories, + IEnumerable WinnersPreview, + PublicSiteContentDto SiteContent, + IEnumerable Faq); diff --git a/Backend/Contracts/PublicSeasonCategoryContracts.cs b/Backend/Contracts/PublicSeasonCategoryContracts.cs new file mode 100644 index 0000000..c9df6ea --- /dev/null +++ b/Backend/Contracts/PublicSeasonCategoryContracts.cs @@ -0,0 +1,23 @@ +namespace Backend.Contracts; + +public sealed record CandidateSummaryDto( + int Id, + string DisplayName, + string ChannelSlug, + string Platform, + string? ClipUrl, + string? ClipTitle, + string? ClipPlatform); + +public sealed record PublicCategoryDetailDto( + int Id, + string Name, + string GroupName, + string Description, + int MaxNomineesPerUser, + IEnumerable Candidates); + +public sealed record SeasonCategoriesResponse( + int SeasonId, + int Year, + IEnumerable Categories); diff --git a/Backend/Contracts/PublicUserParticipationContracts.cs b/Backend/Contracts/PublicUserParticipationContracts.cs new file mode 100644 index 0000000..31630b7 --- /dev/null +++ b/Backend/Contracts/PublicUserParticipationContracts.cs @@ -0,0 +1,28 @@ +namespace Backend.Contracts; + +public sealed record UserNominationStateDto( + int CategoryId, + string[] Nominees); + +public sealed record UserVoteStateDto( + int CategoryId, + int CandidateId); + +public sealed record UserClipSubmissionStateDto( + int Id, + int? CategoryId, + string ClipUrl, + string Title, + string Creator, + string Platform, + string Status, + DateTimeOffset CreatedAt, + string? ReviewNote, + DateTimeOffset? ReviewedAt); + +public sealed record UserParticipationResponse( + int SeasonId, + int Year, + UserNominationStateDto[] Nominations, + UserVoteStateDto[] Votes, + UserClipSubmissionStateDto[] ClipSubmissions); diff --git a/Backend/Contracts/PublicWinnerArchiveContracts.cs b/Backend/Contracts/PublicWinnerArchiveContracts.cs new file mode 100644 index 0000000..372676b --- /dev/null +++ b/Backend/Contracts/PublicWinnerArchiveContracts.cs @@ -0,0 +1,12 @@ +namespace Backend.Contracts; + +public sealed record WinnerArchiveItemDto( + string Category, + string WinnerName, + string WinnerSlug, + string WinnerPlatform, + string WinnerUrl); + +public sealed record WinnerArchiveResponse( + int Year, + IEnumerable Items); diff --git a/Backend/Contracts/PublicWriteContracts.cs b/Backend/Contracts/PublicWriteContracts.cs new file mode 100644 index 0000000..957b9c0 --- /dev/null +++ b/Backend/Contracts/PublicWriteContracts.cs @@ -0,0 +1,30 @@ +namespace Backend.Contracts; + +public sealed record NominationEntryRequest( + string Name, + string StreamUrl); + +public sealed record CreateNominationRequest( + int Year, + int CategoryId, + string TwitchUserId, + string[]? Nominees, + NominationEntryRequest[]? Nominations); + +public sealed record VoteEntryRequest( + int CategoryId, + int CandidateId); + +public sealed record CreateVoteRequest( + int SeasonId, + string TwitchUserId, + VoteEntryRequest[] Entries); + +public sealed record CreateClipRequest( + int Year, + int? CategoryId, + int? CandidateId, + string TwitchUserId, + string ClipUrl, + string Title, + string Creator); diff --git a/Backend/Data/AwardsDbContext.cs b/Backend/Data/AwardsDbContext.cs index bde6be6..4657e97 100644 --- a/Backend/Data/AwardsDbContext.cs +++ b/Backend/Data/AwardsDbContext.cs @@ -16,6 +16,7 @@ public sealed class AwardsDbContext(DbContextOptions options) : public DbSet RiskFlags => Set(); public DbSet AdminAuditEntries => Set(); public DbSet ClipSubmissions => Set(); + public DbSet SiteSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -23,9 +24,29 @@ public sealed class AwardsDbContext(DbContextOptions options) : { entity.HasIndex(item => item.Year).IsUnique(); entity.Property(item => item.Name).HasMaxLength(160); + entity.Property(item => item.ShowStreamUrl).HasMaxLength(400); entity.Property(item => item.CurrentPhase).HasMaxLength(60); }); + modelBuilder.Entity(entity => + { + entity.Property(item => item.HostDisplayName).HasMaxLength(120); + entity.Property(item => item.HostTagline).HasMaxLength(160); + entity.Property(item => item.NewsletterUrl).HasMaxLength(400); + entity.Property(item => item.PrivacyEmail).HasMaxLength(160); + entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120); + entity.Property(item => item.ImprintUrl).HasMaxLength(400); + entity.Property(item => item.ContactUrl).HasMaxLength(400); + entity.Property(item => item.SponsorsUrl).HasMaxLength(400); + entity.Property(item => item.DemoLoginEmail).HasMaxLength(180); + entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120); + entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80); + entity.Property(item => item.DemoLoginTwitchUserId).HasMaxLength(120); + entity.Property(item => item.DemoLoginDisplayName).HasMaxLength(120); + entity.Property(item => item.MaintenanceTitle).HasMaxLength(120); + entity.Property(item => item.MaintenanceMessage).HasMaxLength(600); + }); + modelBuilder.Entity(entity => { entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique(); @@ -45,6 +66,11 @@ public sealed class AwardsDbContext(DbContextOptions options) : { entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120); entity.Property(item => item.CandidateText).HasMaxLength(120); + entity.Property(item => item.StreamUrl).HasMaxLength(300); + entity.Property(item => item.Status).HasMaxLength(20); + entity.Property(item => item.ReviewNote).HasMaxLength(500); + entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120); + entity.HasIndex(item => new { item.SeasonId, item.Status }); }); modelBuilder.Entity(entity => @@ -55,6 +81,7 @@ public sealed class AwardsDbContext(DbContextOptions options) : modelBuilder.Entity(entity => { + entity.HasIndex(item => new { item.SeasonId, item.CategoryId }).IsUnique(); entity.Property(item => item.CategoryName).HasMaxLength(120); }); @@ -79,6 +106,7 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.Summary).HasMaxLength(240); entity.Property(item => item.CreatedFromIp).HasMaxLength(80); entity.Property(item => item.UserAgent).HasMaxLength(400); + entity.Property(item => item.ReviewNote).HasMaxLength(500); entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120); }); @@ -89,6 +117,8 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.EntityType).HasMaxLength(80); entity.Property(item => item.EntityId).HasMaxLength(120); entity.Property(item => item.Summary).HasMaxLength(240); + entity.Property(item => item.CreatedFromIp).HasMaxLength(80); + entity.Property(item => item.UserAgent).HasMaxLength(400); }); modelBuilder.Entity(entity => @@ -99,8 +129,15 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.Creator).HasMaxLength(120); entity.Property(item => item.Platform).HasMaxLength(40); entity.Property(item => item.Status).HasMaxLength(20); + entity.Property(item => item.ReviewNote).HasMaxLength(500); + entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120); entity.Property(item => item.CreatedFromIp).HasMaxLength(80); entity.HasIndex(item => new { item.SeasonId, item.Status }); + entity.HasIndex(item => item.CandidateId); + entity.HasOne(item => item.Candidate) + .WithMany() + .HasForeignKey(item => item.CandidateId) + .OnDelete(DeleteBehavior.SetNull); }); SeedData.Apply(modelBuilder); diff --git a/Backend/Data/DesignTimeDbContextFactory.cs b/Backend/Data/DesignTimeDbContextFactory.cs index 4d01635..412efc4 100644 --- a/Backend/Data/DesignTimeDbContextFactory.cs +++ b/Backend/Data/DesignTimeDbContextFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; namespace Backend.Data; @@ -7,9 +8,24 @@ public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory(); - var connectionString = Environment.GetEnvironmentVariable("VTSA_POSTGRES") - ?? "Host=localhost;Port=5432;Database=vtuber_star_awards;Username=postgres;Password=postgres"; + var connectionString = configuration["VTSA_POSTGRES"] + ?? configuration.GetConnectionString("Postgres"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "No PostgreSQL connection string configured for design-time EF operations. " + + "Set VTSA_POSTGRES or ConnectionStrings__Postgres before running dotnet ef."); + } optionsBuilder.UseNpgsql(connectionString); return new AwardsDbContext(optionsBuilder.Options); diff --git a/Backend/Data/OperationalTablesBootstrapper.cs b/Backend/Data/OperationalTablesBootstrapper.cs index 7428feb..57be98a 100644 --- a/Backend/Data/OperationalTablesBootstrapper.cs +++ b/Backend/Data/OperationalTablesBootstrapper.cs @@ -13,6 +13,9 @@ public static class OperationalTablesBootstrapper ALTER TABLE "UserSessions" ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT ''; + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]'; + CREATE TABLE IF NOT EXISTS "RiskFlags" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "SeasonId" integer NULL, @@ -25,11 +28,15 @@ public static class OperationalTablesBootstrapper "CreatedFromIp" character varying(80) NOT NULL, "UserAgent" character varying(400) NOT NULL, "MetadataJson" text NOT NULL, + "ReviewNote" character varying(500) NULL, "ReviewedByTwitchId" character varying(120) NULL, "CreatedAt" timestamp with time zone NOT NULL, "ReviewedAt" timestamp with time zone NULL ); + ALTER TABLE "RiskFlags" + ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL; + CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt" ON "RiskFlags" ("Status", "CreatedAt" DESC); @@ -44,9 +51,17 @@ public static class OperationalTablesBootstrapper "EntityId" character varying(120) NOT NULL, "Summary" character varying(240) NOT NULL, "MetadataJson" text NOT NULL, + "CreatedFromIp" character varying(80) NOT NULL DEFAULT '', + "UserAgent" character varying(400) NOT NULL DEFAULT '', "CreatedAt" timestamp with time zone NOT NULL ); + ALTER TABLE "AdminAuditEntries" + ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT ''; + + ALTER TABLE "AdminAuditEntries" + ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT ''; + CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt" ON "AdminAuditEntries" ("CreatedAt" DESC); @@ -64,7 +79,54 @@ public static class OperationalTablesBootstrapper "CreatedAt" timestamp with time zone NOT NULL ); + ALTER TABLE "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "CandidateId" integer NULL; + + ALTER TABLE "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL; + + ALTER TABLE "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL; + + ALTER TABLE "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL; + CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status" ON "ClipSubmissions" ("SeasonId", "Status"); + + CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId" + ON "ClipSubmissions" ("CandidateId"); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId' + ) THEN + ALTER TABLE "ClipSubmissions" + ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId" + FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id") + ON DELETE SET NULL; + END IF; + END $$; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending'; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL; + + CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status" + ON "Nominations" ("SeasonId", "Status"); """); } diff --git a/Backend/Data/SeedAwardCatalogBootstrapper.cs b/Backend/Data/SeedAwardCatalogBootstrapper.cs new file mode 100644 index 0000000..b8fb807 --- /dev/null +++ b/Backend/Data/SeedAwardCatalogBootstrapper.cs @@ -0,0 +1,145 @@ +using Backend.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Data; + +public static partial class SeedDataBootstrapper +{ + private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season) + { + var seasonCategories = await db.Categories + .Where(item => item.SeasonId == season.Id) + .ToArrayAsync(); + + foreach (var category in seasonCategories) + { + if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug)) + { + continue; + } + + var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug); + category.GroupName = target.GroupName; + category.Name = target.Name; + category.Slug = target.Slug; + category.Description = target.Description; + category.SortOrder = target.SortOrder; + category.MaxNomineesPerUser = 3; + } + + await db.SaveChangesAsync(); + + var existing = await db.Categories + .Where(item => item.SeasonId == season.Id) + .Select(item => item.Slug) + .ToArrayAsync(); + var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var seed in SeedCatalog.CategorySeeds) + { + if (existingSlugs.Contains(seed.Slug)) + { + continue; + } + + db.Categories.Add(new Category + { + SeasonId = season.Id, + GroupName = seed.GroupName, + Name = seed.Name, + Slug = seed.Slug, + Description = seed.Description, + SortOrder = seed.SortOrder, + MaxNomineesPerUser = 3, + }); + } + + await db.SaveChangesAsync(); + } + + private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds) + { + var categories = await db.Categories + .Where(item => item.SeasonId == season.Id) + .ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase); + var existing = await db.Candidates + .Where(item => item.SeasonId == season.Id) + .Select(item => new { item.CategoryId, item.DisplayName, item.ChannelSlug }) + .ToArrayAsync(); + var existingKeys = existing + .Select(item => $"{item.CategoryId}|{item.DisplayName}|{item.ChannelSlug}".ToLowerInvariant()) + .ToHashSet(); + + foreach (var seed in seeds) + { + if (!categories.TryGetValue(seed.CategorySlug, out var category)) + { + continue; + } + + var key = $"{category.Id}|{seed.DisplayName}|{seed.ChannelSlug}".ToLowerInvariant(); + if (existingKeys.Contains(key)) + { + continue; + } + + db.Candidates.Add(new Candidate + { + SeasonId = season.Id, + CategoryId = category.Id, + DisplayName = seed.DisplayName, + ChannelSlug = seed.ChannelSlug, + Platform = seed.Platform, + }); + } + + await db.SaveChangesAsync(); + } + + private static async Task EnsureWinnersAsync(AwardsDbContext db, Season season, WinnerSeed[] seeds) + { + var categories = await db.Categories + .Where(item => item.SeasonId == season.Id) + .ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase); + var candidates = await db.Candidates + .Where(item => item.SeasonId == season.Id) + .ToArrayAsync(); + var existingResults = await db.Results + .Where(item => item.SeasonId == season.Id) + .ToArrayAsync(); + foreach (var result in existingResults) + { + if (categories.Values.FirstOrDefault(item => item.Id == result.CategoryId) is { } category + && result.CategoryName != category.Name) + { + result.CategoryName = category.Name; + } + } + + var existingResultCategoryIds = existingResults.Select(item => item.CategoryId).ToHashSet(); + + foreach (var seed in seeds) + { + if (!categories.TryGetValue(seed.CategorySlug, out var category) || existingResultCategoryIds.Contains(category.Id)) + { + continue; + } + + var candidate = candidates.FirstOrDefault(item => + item.CategoryId == category.Id + && string.Equals(item.DisplayName, seed.DisplayName, StringComparison.OrdinalIgnoreCase)); + if (candidate is null) + { + continue; + } + + db.Results.Add(new AwardResult + { + SeasonId = season.Id, + CategoryId = category.Id, + CandidateId = candidate.Id, + CategoryName = category.Name, + }); + } + } +} diff --git a/Backend/Data/SeedCatalog.cs b/Backend/Data/SeedCatalog.cs new file mode 100644 index 0000000..92fae5a --- /dev/null +++ b/Backend/Data/SeedCatalog.cs @@ -0,0 +1,106 @@ +namespace Backend.Data; + +internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder); +internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform); +internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform); +internal sealed record SiteFaqSeed(string Question, string Answer); +internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon); + +internal static class SeedCatalog +{ + internal static readonly CategorySeed[] CategorySeeds = + [ + new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1), + new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2), + new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3), + new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4), + new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5), + new("Entertainment", "Best Variety", "best-variety", "Talk, Comedy, Watchalongs und kreative Streamformate.", 6), + new("Community", "Community Liebling", "community-liebling", "Creator:innen, die ihre Community besonders stark verbinden.", 7), + new("Collab", "Best Collab & Duo", "best-collab-duo", "Gemeinsame Streams, Projekte und Duo-Dynamik.", 8), + ]; + + internal static readonly Dictionary LegacyCategorySlugMap = new(StringComparer.OrdinalIgnoreCase) + { + ["bestes-live-event"] = "best-newcomer", + ["clip-des-jahres"] = "model-design", + ["beste-community"] = "gesang-musik", + }; + + internal static readonly SiteFaqSeed[] SiteFaqSeeds = + [ + new( + "Wer darf nominiert werden?", + "Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."), + new( + "Wie funktioniert das Voting?", + "Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."), + new( + "Was kostet die Teilnahme?", + "Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."), + new( + "Wann und wo findet die Award-Show statt?", + "Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"), + new( + "Ich wurde nominiert — was nun?", + "Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."), + ]; + + internal static readonly SiteSocialSeed[] SiteSocialSeeds = + [ + new("Twitch", "twitch", "https://twitch.tv/jayuhime", "twitch"), + new("YouTube", "youtube", "https://youtube.com/c/Jayuhime", "youtube"), + new("X", "x", "https://x.com/jayuhime", "x"), + new("Instagram", "instagram", "https://instagram.com/jayuhime", "instagram"), + new("Discord", "discord", "https://discord.gg/jayuhime", "discord"), + ]; + + internal static readonly CandidateSeed[] CurrentCandidateSeeds = + [ + new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), + new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"), + new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"), + new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"), + new("best-newcomer", "Nox Live", "@noxlive", "Twitch"), + new("model-design", "Velvet Rei", "@velvetrei", "Twitch"), + new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"), + new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"), + new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"), + new("best-gaming", "Kurainu", "@kurainu", "Twitch"), + new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"), + new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"), + new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"), + new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"), + new("community-liebling", "Lumi", "@lumi_vt", "Cake"), + new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"), + new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"), + ]; + + internal static readonly WinnerSeed[] WinnerSeeds = + [ + new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), + new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"), + new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"), + new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"), + new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"), + new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"), + new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"), + new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"), + new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"), + new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"), + new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"), + new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"), + new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"), + new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"), + new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"), + new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"), + new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"), + new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"), + new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"), + new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"), + new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"), + new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"), + new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"), + new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"), + ]; +} diff --git a/Backend/Data/SeedData.cs b/Backend/Data/SeedData.cs index 0f45749..2dd4ec7 100644 --- a/Backend/Data/SeedData.cs +++ b/Backend/Data/SeedData.cs @@ -1,5 +1,7 @@ using Backend.Domain; +using Backend.Services; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace Backend.Data; @@ -7,12 +9,68 @@ public static class SeedData { public static void Apply(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasData( + new SiteSettings + { + Id = 1, + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = """ +Verantwortliche:r +VTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de + +Welche Daten wir verarbeiten +Bei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion. +Für Show-Erinnerungen speichern wir optional deine E-Mail-Adresse. + +Rechtsgrundlage +Verarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO. + +Zweck der Verarbeitung +Durchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen. + +Löschfristen +Alle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt. + +Deine Rechte +Du hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde. + +Weitergabe an Dritte +Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung. +""", + PrivacyPolicyUpdatedBy = "seed", + PrivacyPolicyUpdatedAt = new DateTimeOffset(2026, 6, 23, 0, 0, 0, TimeSpan.Zero), + ImprintUrl = "https://vtuber-star-awards.de/impressum", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults), + SocialLinksJson = JsonSerializer.Serialize(new[] + { + new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true }, + new { label = "YouTube", platform = "youtube", url = "https://youtube.com/c/Jayuhime", icon = "youtube", showOnHost = true, showOnCommunity = true }, + new { label = "X", platform = "x", url = "https://x.com/jayuhime", icon = "x", showOnHost = true, showOnCommunity = true }, + new { label = "Instagram", platform = "instagram", url = "https://instagram.com/jayuhime", icon = "instagram", showOnHost = true, showOnCommunity = true }, + new { label = "Discord", platform = "discord", url = "https://discord.gg/jayuhime", icon = "discord", showOnHost = true, showOnCommunity = true }, + }), + FaqJson = JsonSerializer.Serialize(new[] + { + new { question = "Wer darf nominiert werden?", answer = "Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhängig von Follower-Zahl oder Plattform. Die Community schlägt in der Nominierungsphase ihre Favorit:innen vor." }, + new { question = "Wie funktioniert das Voting?", answer = "Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase." }, + new { question = "Was kostet die Teilnahme?", answer = "Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans." }, + new { question = "Wann und wo findet die Award-Show statt?", answer = "Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!" }, + new { question = "Ich wurde nominiert — was nun?", answer = "Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt." }, + }), + }); + modelBuilder.Entity().HasData( new Season { Id = 1, Year = 2026, Name = "VTuber Star Awards 2026", + ShowStreamUrl = "https://twitch.tv/jayuhime", IsCurrent = true, IsCommunityOnly = true, CurrentPhase = "Community Voting", @@ -23,12 +81,14 @@ public static class SeedData ReviewStartsAt = new DateOnly(2026, 7, 1), ReviewEndsAt = new DateOnly(2026, 7, 10), ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0), }, new Season { Id = 2, Year = 2025, Name = "VTuber Star Awards 2025", + ShowStreamUrl = "https://twitch.tv/jayuhime", IsCurrent = false, IsCommunityOnly = true, CurrentPhase = "Archived", @@ -39,12 +99,14 @@ public static class SeedData ReviewStartsAt = new DateOnly(2025, 7, 1), ReviewEndsAt = new DateOnly(2025, 7, 10), ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0), }, new Season { Id = 3, Year = 2024, Name = "VTuber Star Awards 2024", + ShowStreamUrl = "https://youtube.com/c/Jayuhime", IsCurrent = false, IsCommunityOnly = true, CurrentPhase = "Archived", @@ -55,12 +117,14 @@ public static class SeedData ReviewStartsAt = new DateOnly(2024, 7, 1), ReviewEndsAt = new DateOnly(2024, 7, 10), ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0), }, new Season { Id = 4, Year = 2023, Name = "VTuber Star Awards 2023", + ShowStreamUrl = "https://twitch.tv/jayuhime", IsCurrent = false, IsCommunityOnly = true, CurrentPhase = "Archived", @@ -71,6 +135,7 @@ public static class SeedData ReviewStartsAt = new DateOnly(2023, 7, 1), ReviewEndsAt = new DateOnly(2023, 7, 10), ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0), }); modelBuilder.Entity().HasData( @@ -101,12 +166,12 @@ public static class SeedData new Candidate { Id = 13, SeasonId = 4, CategoryId = 10, DisplayName = "Tenshi Vox", ChannelSlug = "@tenshivox", Platform = "Twitch" }); modelBuilder.Entity().HasData( - new AwardResult { Id = 1, SeasonId = 2, CandidateId = 8, CategoryName = "VTuber des Jahres" }, - new AwardResult { Id = 2, SeasonId = 2, CandidateId = 9, CategoryName = "Bestes Live Event" }, - new AwardResult { Id = 3, SeasonId = 2, CandidateId = 10, CategoryName = "Clip des Jahres" }, - new AwardResult { Id = 4, SeasonId = 3, CandidateId = 11, CategoryName = "VTuber des Jahres" }, - new AwardResult { Id = 5, SeasonId = 3, CandidateId = 12, CategoryName = "Clip des Jahres" }, - new AwardResult { Id = 6, SeasonId = 4, CandidateId = 13, CategoryName = "VTuber des Jahres" }); + new AwardResult { Id = 1, SeasonId = 2, CategoryId = 5, CandidateId = 8, CategoryName = "VTuber des Jahres" }, + new AwardResult { Id = 2, SeasonId = 2, CategoryId = 6, CandidateId = 9, CategoryName = "Bestes Live Event" }, + new AwardResult { Id = 3, SeasonId = 2, CategoryId = 7, CandidateId = 10, CategoryName = "Clip des Jahres" }, + new AwardResult { Id = 4, SeasonId = 3, CategoryId = 8, CandidateId = 11, CategoryName = "VTuber des Jahres" }, + new AwardResult { Id = 5, SeasonId = 3, CategoryId = 9, CandidateId = 12, CategoryName = "Clip des Jahres" }, + new AwardResult { Id = 6, SeasonId = 4, CategoryId = 10, CandidateId = 13, CategoryName = "VTuber des Jahres" }); modelBuilder.Entity().HasData( new Nomination { Id = 1, SeasonId = 1, CategoryId = 1, SubmittedByTwitchId = "twitch_hoshi", CandidateText = "Hoshimi Miyu", CreatedAt = new DateTimeOffset(2026, 6, 10, 13, 0, 0, TimeSpan.Zero) }, diff --git a/Backend/Data/SeedDataBootstrapper.cs b/Backend/Data/SeedDataBootstrapper.cs new file mode 100644 index 0000000..15c344f --- /dev/null +++ b/Backend/Data/SeedDataBootstrapper.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; + +namespace Backend.Data; + +public static partial class SeedDataBootstrapper +{ + public static async Task EnsureAsync(AwardsDbContext db) + { + await EnsureSiteSettingsAsync(db); + + var seasons = await db.Seasons.ToDictionaryAsync(item => item.Year); + if (seasons.Count == 0) + { + return; + } + + foreach (var season in seasons.Values) + { + await EnsureCategoriesAsync(db, season); + } + + if (seasons.TryGetValue(2026, out var currentSeason)) + { + await EnsureCandidatesAsync(db, currentSeason, SeedCatalog.CurrentCandidateSeeds); + await EnsureSeedOperationalDataAsync(db, currentSeason); + } + + foreach (var year in new[] { 2025, 2024, 2023 }) + { + if (!seasons.TryGetValue(year, out var season)) + { + continue; + } + + var winners = SeedCatalog.WinnerSeeds.Where(item => item.Year == year).ToArray(); + await EnsureCandidatesAsync(db, season, winners.Select(item => new CandidateSeed(item.CategorySlug, item.DisplayName, item.ChannelSlug, item.Platform)).ToArray()); + await EnsureWinnersAsync(db, season, winners); + } + + await db.SaveChangesAsync(); + } +} diff --git a/Backend/Data/SeedOperationalDataBootstrapper.cs b/Backend/Data/SeedOperationalDataBootstrapper.cs new file mode 100644 index 0000000..c19b564 --- /dev/null +++ b/Backend/Data/SeedOperationalDataBootstrapper.cs @@ -0,0 +1,283 @@ +using System.Text.Json; +using Backend.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Data; + +public static partial class SeedDataBootstrapper +{ + private static async Task EnsureSeedOperationalDataAsync(AwardsDbContext db, Season season) + { + var categories = await db.Categories + .Where(item => item.SeasonId == season.Id) + .ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase); + var candidates = await db.Candidates + .Where(item => item.SeasonId == season.Id) + .ToArrayAsync(); + + var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db); + + if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id)) + { + db.ClipSubmissions.AddRange( + new ClipSubmission + { + SeasonId = season.Id, + CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"), + CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"), + SubmittedByTwitchId = "local_user_3", + ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment", + Title = "Starlight Debut Moment", + Creator = "Hoshimi Miyu", + Platform = "Twitch", + Status = "approved", + ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.", + ReviewedByTwitchId = "jayuhime_admin", + CreatedFromIp = "127.0.0.1", + CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero), + ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 5, 0, TimeSpan.Zero), + }, + new ClipSubmission + { + SeasonId = season.Id, + CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"), + CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"), + SubmittedByTwitchId = "local_user_4", + ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype", + Title = "Finale-Hype mit Chat-Chaos", + Creator = "Kurainu", + Platform = "Twitch", + Status = "approved", + ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.", + ReviewedByTwitchId = "jayuhime_admin", + CreatedFromIp = "127.0.0.1", + CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero), + ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 10, 0, TimeSpan.Zero), + }, + new ClipSubmission + { + SeasonId = season.Id, + CategoryId = ResolveCategoryId(categories, "best-gaming"), + CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"), + SubmittedByTwitchId = "local_user", + ClipUrl = "https://clips.twitch.tv/EpicGamingMoment", + Title = "Epischer Clutch im Finale", + Creator = "Kurainu", + Platform = "Twitch", + Status = "pending", + CreatedFromIp = "127.0.0.1", + CreatedAt = new DateTimeOffset(2026, 6, 17, 9, 10, 0, TimeSpan.Zero), + }, + new ClipSubmission + { + SeasonId = season.Id, + CategoryId = ResolveCategoryId(categories, "gesang-musik"), + CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"), + SubmittedByTwitchId = "local_user_2", + ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment", + Title = "Live-Cover mit Gänsehaut", + Creator = "Melo Diva", + Platform = "YouTube", + Status = "approved", + ReviewNote = "Geprüfter Clip fuer Review-Workflow.", + ReviewedByTwitchId = "jayuhime_admin", + CreatedFromIp = "127.0.0.1", + CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero), + ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 0, 0, TimeSpan.Zero), + }); + } + + if (!normalizedLegacyState.HasRiskSeed && !await db.RiskFlags.AnyAsync(item => item.Source == "seed")) + { + db.RiskFlags.Add(new RiskFlag + { + SeasonId = season.Id, + TwitchUserId = "sample_user", + Source = "seed", + Type = "rapid_vote_updates", + Severity = "medium", + Status = "open", + Summary = "Mehrere Voting-Aenderungen in kurzer Zeit erkannt.", + CreatedFromIp = "127.0.0.1", + UserAgent = "seed-bootstrap", + MetadataJson = JsonSerializer.Serialize(new { recentVoteSubmissions = 3 }), + CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 40, 0, TimeSpan.Zero), + }); + } + + if (!normalizedLegacyState.HasAuditSeed && !await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize")) + { + db.AdminAuditEntries.Add(new AdminAuditEntry + { + AdminTwitchUserId = "system", + ActionType = "seed.initialize", + EntityType = "database", + EntityId = season.Year.ToString(), + Summary = "Startinhalte wurden in der Datenbank bereitgestellt.", + MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }), + CreatedFromIp = "seed", + UserAgent = "seed-bootstrap", + CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero), + }); + } + } + + private static int? ResolveCategoryId(IReadOnlyDictionary categories, string slug) => + categories.TryGetValue(slug, out var category) ? category.Id : null; + + private static int? ResolveCandidateId( + IReadOnlyDictionary categories, + IEnumerable candidates, + string categorySlug, + string displayName) + { + var categoryId = ResolveCategoryId(categories, categorySlug); + return categoryId is int resolvedCategoryId + ? candidates.FirstOrDefault(item => + item.CategoryId == resolvedCategoryId + && string.Equals(item.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))?.Id + : null; + } + + private static async Task NormalizeLegacyDemoLabelsAsync(AwardsDbContext db) + { + var legacySessions = await db.UserSessions + .Where(item => item.TwitchUserId == "admin_demo" || item.TwitchUserId == "jayuhime_demo" || item.TwitchUserId == "demo_user") + .ToArrayAsync(); + + foreach (var session in legacySessions) + { + session.TwitchUserId = session.TwitchUserId switch + { + "admin_demo" => "jayuhime_admin", + "jayuhime_demo" => "jayuhime_viewer", + "demo_user" => "local_user", + _ => session.TwitchUserId, + }; + session.DisplayName = session.DisplayName switch + { + "Admin Demo" => "Jayuhime Admin", + "Demo User" => "Local User", + _ => session.DisplayName, + }; + } + + var legacyClipSubmissions = await db.ClipSubmissions + .Where(item => + item.SubmittedByTwitchId == "demo_user" || + item.SubmittedByTwitchId == "demo_user_2" || + item.ClipUrl.Contains("Demo") || + item.ClipUrl.Contains("demo") || + (item.ReviewNote != null && item.ReviewNote.Contains("Demo-Clip"))) + .ToArrayAsync(); + + foreach (var clip in legacyClipSubmissions) + { + clip.SubmittedByTwitchId = clip.SubmittedByTwitchId switch + { + "demo_user" => "local_user", + "demo_user_2" => "local_user_2", + _ => clip.SubmittedByTwitchId, + }; + clip.ClipUrl = clip.ClipUrl + .Replace("DemoGamingMoment", "EpicGamingMoment") + .Replace("demoSong", "liveCoverMoment"); + clip.ReviewNote = clip.ReviewNote?.Replace("Demo-Clip", "Geprüfter Clip"); + } + await LinkExistingClipsToCandidatesAsync(db); + + var legacyRiskFlags = await db.RiskFlags + .Where(item => + item.Source == "demo" || + item.Summary.StartsWith("Demo:") || + item.TwitchUserId == "jayuhime_demo" || + item.TwitchUserId == "demo_user") + .ToArrayAsync(); + + foreach (var flag in legacyRiskFlags) + { + flag.Source = "seed"; + flag.TwitchUserId = flag.TwitchUserId switch + { + "demo_user" => "local_user", + "jayuhime_demo" => "jayuhime_viewer", + _ => flag.TwitchUserId, + }; + flag.Summary = flag.Summary.Replace("Demo: ", string.Empty); + flag.UserAgent = flag.UserAgent == "demo-seed" ? "seed-bootstrap" : flag.UserAgent; + } + + var legacyAuditEntries = await db.AdminAuditEntries + .Where(item => + item.ActionType == "demo.seed" || + item.Summary.Contains("Demo-Inhalte") || + item.AdminTwitchUserId == "admin_demo" || + item.AdminTwitchUserId == "jayuhime_demo") + .ToArrayAsync(); + + foreach (var entry in legacyAuditEntries) + { + entry.AdminTwitchUserId = entry.AdminTwitchUserId switch + { + "admin_demo" => "jayuhime_admin", + "jayuhime_demo" => "jayuhime_viewer", + _ => entry.AdminTwitchUserId, + }; + if (entry.ActionType == "demo.seed") + { + entry.ActionType = "seed.initialize"; + } + if (entry.Summary.Contains("Demo-Inhalte")) + { + entry.Summary = "Startinhalte wurden in der Datenbank bereitgestellt."; + } + } + + return new LegacySeedState( + legacyRiskFlags.Length > 0 || await db.RiskFlags.AnyAsync(item => item.Source == "seed"), + legacyAuditEntries.Length > 0 || await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize")); + } + + private static async Task LinkExistingClipsToCandidatesAsync(AwardsDbContext db) + { + var clips = await db.ClipSubmissions + .Where(item => item.CandidateId == null && item.CategoryId != null && item.Creator != string.Empty) + .ToArrayAsync(); + if (clips.Length == 0) + { + return; + } + + var seasonIds = clips.Select(item => item.SeasonId).Distinct().ToArray(); + var categoryIds = clips.Select(item => item.CategoryId!.Value).Distinct().ToArray(); + var candidates = await db.Candidates + .Where(item => seasonIds.Contains(item.SeasonId) && categoryIds.Contains(item.CategoryId)) + .ToArrayAsync(); + + foreach (var clip in clips) + { + var creatorKey = NormalizeSeedCandidateKey(clip.Creator); + var candidate = candidates.FirstOrDefault(item => + item.SeasonId == clip.SeasonId + && item.CategoryId == clip.CategoryId + && (NormalizeSeedCandidateKey(item.DisplayName) == creatorKey + || NormalizeSeedCandidateKey(item.ChannelSlug) == creatorKey)); + + if (candidate is not null) + { + clip.CandidateId = candidate.Id; + } + } + } + + private static string NormalizeSeedCandidateKey(string value) => + new( + value + .Trim() + .TrimStart('@') + .ToLowerInvariant() + .Where(char.IsLetterOrDigit) + .ToArray()); + + private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed); +} diff --git a/Backend/Data/SeedSiteSettingsBootstrapper.cs b/Backend/Data/SeedSiteSettingsBootstrapper.cs new file mode 100644 index 0000000..9dd6c50 --- /dev/null +++ b/Backend/Data/SeedSiteSettingsBootstrapper.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Data; + +public static partial class SeedDataBootstrapper +{ + private static async Task EnsureSiteSettingsAsync(AwardsDbContext db) + { + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return; + } + + if (!HasValidSiteArray(settings.FaqJson, "question", "answer")) + { + settings.FaqJson = JsonSerializer.Serialize(SeedCatalog.SiteFaqSeeds.Select(item => new + { + question = item.Question, + answer = item.Answer, + })); + } + + if (!HasValidSiteArray(settings.SocialLinksJson, "label", "platform", "url")) + { + settings.SocialLinksJson = JsonSerializer.Serialize(SeedCatalog.SiteSocialSeeds.Select(item => new + { + label = item.Label, + platform = item.Platform, + url = item.Url, + icon = item.Icon, + showOnHost = true, + showOnCommunity = true, + })); + } + + if (!HasValidRiskRules(settings.RiskRulesJson)) + { + settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults); + } + } + + private static bool HasValidSiteArray(string? json, params string[] requiredKeys) + { + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + return document.RootElement.EnumerateArray().Any(item => + item.ValueKind == JsonValueKind.Object + && requiredKeys.All(key => + item.TryGetProperty(key, out var value) + && value.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(value.GetString()))); + } + catch (JsonException) + { + return false; + } + } + + private static bool HasValidRiskRules(string? json) => + RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length; +} diff --git a/Backend/Domain/AdminAuditEntry.cs b/Backend/Domain/AdminAuditEntry.cs index ef13626..d1f46af 100644 --- a/Backend/Domain/AdminAuditEntry.cs +++ b/Backend/Domain/AdminAuditEntry.cs @@ -9,5 +9,7 @@ public sealed class AdminAuditEntry public string EntityId { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty; public string MetadataJson { get; set; } = "{}"; + public string CreatedFromIp { get; set; } = string.Empty; + public string UserAgent { get; set; } = string.Empty; public DateTimeOffset CreatedAt { get; set; } } diff --git a/Backend/Domain/AwardResult.cs b/Backend/Domain/AwardResult.cs index 1568429..8eab67a 100644 --- a/Backend/Domain/AwardResult.cs +++ b/Backend/Domain/AwardResult.cs @@ -5,6 +5,8 @@ public sealed class AwardResult public int Id { get; set; } public int SeasonId { get; set; } public Season Season { get; set; } = null!; + public int CategoryId { get; set; } + public Category Category { get; set; } = null!; public int CandidateId { get; set; } public Candidate Candidate { get; set; } = null!; public string CategoryName { get; set; } = string.Empty; diff --git a/Backend/Domain/ClipSubmission.cs b/Backend/Domain/ClipSubmission.cs index caf809a..3fd623f 100644 --- a/Backend/Domain/ClipSubmission.cs +++ b/Backend/Domain/ClipSubmission.cs @@ -5,12 +5,17 @@ public sealed class ClipSubmission public int Id { get; set; } public int SeasonId { get; set; } public int? CategoryId { get; set; } + public int? CandidateId { get; set; } + public Candidate? Candidate { get; set; } public string SubmittedByTwitchId { get; set; } = string.Empty; public string ClipUrl { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Creator { get; set; } = string.Empty; public string Platform { 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 DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? ReviewedAt { get; set; } } diff --git a/Backend/Domain/Nomination.cs b/Backend/Domain/Nomination.cs index 3c0384b..61f3415 100644 --- a/Backend/Domain/Nomination.cs +++ b/Backend/Domain/Nomination.cs @@ -11,5 +11,10 @@ public sealed class Nomination public int? CandidateId { get; set; } public Candidate? Candidate { get; set; } public string? CandidateText { get; set; } + public string? StreamUrl { get; set; } + public string Status { get; set; } = "pending"; + public string? ReviewNote { get; set; } + public string? ReviewedByTwitchId { get; set; } public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? ReviewedAt { get; set; } } diff --git a/Backend/Domain/RiskFlag.cs b/Backend/Domain/RiskFlag.cs index 3573964..2f139ba 100644 --- a/Backend/Domain/RiskFlag.cs +++ b/Backend/Domain/RiskFlag.cs @@ -14,6 +14,7 @@ public sealed class RiskFlag public string CreatedFromIp { get; set; } = string.Empty; public string UserAgent { get; set; } = string.Empty; public string MetadataJson { get; set; } = "{}"; + public string? ReviewNote { get; set; } public string? ReviewedByTwitchId { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? ReviewedAt { get; set; } diff --git a/Backend/Domain/Season.cs b/Backend/Domain/Season.cs index 75dc0a0..ef8b461 100644 --- a/Backend/Domain/Season.cs +++ b/Backend/Domain/Season.cs @@ -5,6 +5,7 @@ public sealed class Season public int Id { get; set; } public int Year { get; set; } public string Name { get; set; } = string.Empty; + public string ShowStreamUrl { get; set; } = string.Empty; public bool IsCurrent { get; set; } public bool IsCommunityOnly { get; set; } public string CurrentPhase { get; set; } = string.Empty; @@ -15,6 +16,7 @@ public sealed class Season public DateOnly ReviewStartsAt { get; set; } public DateOnly ReviewEndsAt { get; set; } public DateOnly ShowDate { get; set; } + public TimeOnly ShowStartsAt { get; set; } = new(20, 0); public ICollection Categories { get; set; } = []; public ICollection Results { get; set; } = []; } diff --git a/Backend/Domain/SiteSettings.cs b/Backend/Domain/SiteSettings.cs new file mode 100644 index 0000000..375bc74 --- /dev/null +++ b/Backend/Domain/SiteSettings.cs @@ -0,0 +1,29 @@ +namespace Backend.Domain; + +public sealed class SiteSettings +{ + public int Id { get; set; } + public string HostDisplayName { get; set; } = string.Empty; + public string HostTagline { get; set; } = string.Empty; + public string NewsletterUrl { get; set; } = string.Empty; + public string PrivacyEmail { get; set; } = string.Empty; + public string PrivacyPolicyContent { get; set; } = string.Empty; + public string? PrivacyPolicyUpdatedBy { get; set; } + public DateTimeOffset? PrivacyPolicyUpdatedAt { get; set; } + public string ImprintUrl { get; set; } = string.Empty; + public string ContactUrl { get; set; } = string.Empty; + public string SponsorsUrl { get; set; } = string.Empty; + public string SocialLinksJson { get; set; } = "[]"; + public string FaqJson { get; set; } = "[]"; + public string RiskRulesJson { get; set; } = "[]"; + public bool DemoLoginManagedByDatabase { get; set; } + public bool DemoLoginEnabled { get; set; } + public string DemoLoginEmail { get; set; } = string.Empty; + public string DemoLoginPasswordHash { get; set; } = string.Empty; + public string DemoLoginPasswordSalt { get; set; } = string.Empty; + public string DemoLoginTwitchUserId { get; set; } = "jayuhime_admin"; + public string DemoLoginDisplayName { get; set; } = "Jayuhime Admin"; + public bool MaintenanceModeEnabled { get; set; } + public string MaintenanceTitle { get; set; } = "Sternenpause"; + public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."; +} diff --git a/Backend/Endpoints/AdminClipModerationEndpoints.cs b/Backend/Endpoints/AdminClipModerationEndpoints.cs new file mode 100644 index 0000000..6557448 --- /dev/null +++ b/Backend/Endpoints/AdminClipModerationEndpoints.cs @@ -0,0 +1,86 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminModerationEndpoints +{ + private static async Task DeleteClip( + HttpContext context, + int clipId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId); + if (clip is null) + { + return Results.NotFound(); + } + + db.ClipSubmissions.Remove(clip); + adminAuditService.AddEntry( + session.TwitchUserId, + "clip.delete", + "clip", + clip.Id.ToString(), + $"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.", + new { clip.Platform }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, clipId }); + } + + private static async Task UpdateClipStatus( + HttpContext context, + int clipId, + UpdateClipStatusRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId); + if (clip is null) + { + return Results.NotFound(); + } + + var normalizedStatus = string.IsNullOrWhiteSpace(request.Status) + ? "pending" + : request.Status.Trim().ToLowerInvariant(); + + if (normalizedStatus is not ("pending" or "approved" or "rejected")) + { + return Results.BadRequest(new { message = "Clip status must be pending, approved or rejected." }); + } + + clip.Status = normalizedStatus; + clip.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); + if (normalizedStatus == "pending") + { + clip.ReviewedAt = null; + clip.ReviewedByTwitchId = null; + } + else + { + clip.ReviewedAt = DateTimeOffset.UtcNow; + clip.ReviewedByTwitchId = session.TwitchUserId; + } + + adminAuditService.AddEntry( + session.TwitchUserId, + "clip.status.update", + "clip", + clip.Id.ToString(), + $"Clip-Einreichung {clip.Id} wurde auf {clip.Status} gesetzt.", + new { clip.Status, clip.ReviewNote }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, clipId = clip.Id, status = clip.Status }); + } +} diff --git a/Backend/Endpoints/AdminDashboardEndpoints.cs b/Backend/Endpoints/AdminDashboardEndpoints.cs new file mode 100644 index 0000000..6f4fcbe --- /dev/null +++ b/Backend/Endpoints/AdminDashboardEndpoints.cs @@ -0,0 +1,221 @@ +using Backend.Contracts; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static class AdminDashboardEndpoints +{ + public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/dashboard", GetDashboard).WithName("GetAdminDashboard").WithOpenApi(); + group.MapGet("/audit-entries", GetAuditEntries).WithName("GetAdminAuditEntries").WithOpenApi(); + return group; + } + + private static async Task GetDashboard(AwardsDbContext db) + { + var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent); + if (currentSeason is null) + { + return Results.NotFound(); + } + + var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id); + var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id); + var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id); + var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null); + var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open"); + + var topCategoryNames = await db.VoteEntries + .AsNoTracking() + .Where(item => item.Ballot.SeasonId == currentSeason.Id) + .Select(item => item.Category.Name) + .ToListAsync(); + + var topCategories = topCategoryNames + .GroupBy(name => name) + .Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count())) + .OrderByDescending(item => item.Votes) + .Take(5) + .ToArray(); + + var riskFlags = await db.RiskFlags + .AsNoTracking() + .Where(item => item.Status == "open") + .OrderByDescending(item => item.CreatedAt) + .Take(8) + .ToArrayAsync(); + var riskFlagDtos = riskFlags.Select(AdminRiskFlagMappings.ToDto).ToArray(); + + var auditEntries = await db.AdminAuditEntries + .AsNoTracking() + .OrderByDescending(item => item.CreatedAt) + .Take(8) + .Select(item => new AdminAuditEntryDto( + item.Id, + item.AdminTwitchUserId, + item.ActionType, + item.EntityType, + item.EntityId, + item.Summary, + item.CreatedAt, + item.MetadataJson, + item.CreatedFromIp, + item.UserAgent)) + .ToArrayAsync(); + + var activityItems = auditEntries + .Take(3) + .Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min.")) + .ToArray(); + + return Results.Ok(new AdminDashboardResponse( + new[] + { + new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"), + new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"), + new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"), + new AdminMetricDto("Reviews offen", reviewCount, "Freitext-Nominierungen mit Review-Bedarf"), + new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"), + }, + activityItems, + topCategories, + riskFlagDtos, + auditEntries)); + } + + private static async Task GetAuditEntries( + int? limit, + string? query, + string? admin, + string? action, + string? entityType, + DateTimeOffset? from, + DateTimeOffset? to, + string? cursor, + AwardsDbContext db) + { + var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500); + var search = query?.Trim(); + var auditQuery = db.AdminAuditEntries.AsNoTracking(); + + if (!TryDecodeAuditCursor(cursor, out var decodedCursor)) + { + return Results.BadRequest(new { message = "Invalid audit cursor." }); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var pattern = $"%{search}%"; + auditQuery = auditQuery.Where(item => + EF.Functions.ILike(item.AdminTwitchUserId, pattern) || + EF.Functions.ILike(item.ActionType, pattern) || + EF.Functions.ILike(item.EntityType, pattern) || + EF.Functions.ILike(item.EntityId, pattern) || + EF.Functions.ILike(item.Summary, pattern) || + EF.Functions.ILike(item.MetadataJson, pattern) || + EF.Functions.ILike(item.CreatedFromIp, pattern) || + EF.Functions.ILike(item.UserAgent, pattern)); + } + + if (!string.IsNullOrWhiteSpace(admin)) + { + var normalizedAdmin = admin.Trim(); + auditQuery = auditQuery.Where(item => item.AdminTwitchUserId == normalizedAdmin); + } + + if (!string.IsNullOrWhiteSpace(action)) + { + var normalizedAction = action.Trim(); + auditQuery = auditQuery.Where(item => item.ActionType == normalizedAction); + } + + if (!string.IsNullOrWhiteSpace(entityType)) + { + var normalizedEntityType = entityType.Trim(); + auditQuery = auditQuery.Where(item => item.EntityType == normalizedEntityType); + } + + if (from.HasValue) + { + auditQuery = auditQuery.Where(item => item.CreatedAt >= from.Value); + } + + if (to.HasValue) + { + auditQuery = auditQuery.Where(item => item.CreatedAt <= to.Value); + } + + var totalCount = await auditQuery.CountAsync(); + + if (decodedCursor is not null) + { + auditQuery = auditQuery.Where(item => + item.CreatedAt < decodedCursor.CreatedAt || + (item.CreatedAt == decodedCursor.CreatedAt && item.Id < decodedCursor.Id)); + } + + var page = await auditQuery + .OrderByDescending(item => item.CreatedAt) + .ThenByDescending(item => item.Id) + .Take(normalizedLimit + 1) + .Select(item => new AdminAuditEntryDto( + item.Id, + item.AdminTwitchUserId, + item.ActionType, + item.EntityType, + item.EntityId, + item.Summary, + item.CreatedAt, + item.MetadataJson, + item.CreatedFromIp, + item.UserAgent)) + .ToArrayAsync(); + + var hasMore = page.Length > normalizedLimit; + var entries = page.Take(normalizedLimit).ToArray(); + var nextCursor = hasMore && entries.Length > 0 + ? EncodeAuditCursor(entries[^1]) + : null; + + return Results.Ok(new AdminAuditEntriesResponse( + entries, + totalCount, + entries.Length, + nextCursor, + normalizedLimit)); + } + + private static string EncodeAuditCursor(AdminAuditEntryDto entry) => + $"{entry.CreatedAt.UtcTicks}:{entry.Id}"; + + private static bool TryDecodeAuditCursor(string? cursor, out AuditCursor? decodedCursor) + { + decodedCursor = null; + if (string.IsNullOrWhiteSpace(cursor)) + { + return true; + } + + var parts = cursor.Split(':', 2); + if (parts.Length != 2 || + !long.TryParse(parts[0], out var ticks) || + !int.TryParse(parts[1], out var id)) + { + return false; + } + + try + { + decodedCursor = new AuditCursor(new DateTimeOffset(ticks, TimeSpan.Zero), id); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private sealed record AuditCursor(DateTimeOffset CreatedAt, int Id); +} diff --git a/Backend/Endpoints/AdminEndpointConventions.cs b/Backend/Endpoints/AdminEndpointConventions.cs new file mode 100644 index 0000000..fa66055 --- /dev/null +++ b/Backend/Endpoints/AdminEndpointConventions.cs @@ -0,0 +1,10 @@ +using Backend.Domain; +using Backend.Security; + +namespace Backend.Endpoints; + +internal static class AdminEndpointConventions +{ + public static UserSession CurrentSession(HttpContext context) => + context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context."); +} diff --git a/Backend/Endpoints/AdminEndpoints.cs b/Backend/Endpoints/AdminEndpoints.cs new file mode 100644 index 0000000..e29b3d2 --- /dev/null +++ b/Backend/Endpoints/AdminEndpoints.cs @@ -0,0 +1,36 @@ +using Backend.Security; + +namespace Backend.Endpoints; + +public static class AdminEndpoints +{ + public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/admin") + .AddEndpointFilter(); + + group.MapAdminSiteSettingsEndpoints(); + + var managerGroup = group.MapGroup(string.Empty) + .AddEndpointFilter(RequireAdminWorkspaceRole); + + managerGroup.MapAdminDashboardEndpoints(); + managerGroup.MapAdminSeasonManagementEndpoints(); + managerGroup.MapAdminModerationEndpoints(); + + return app; + } + + private static async ValueTask RequireAdminWorkspaceRole( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + var session = AdminEndpointConventions.CurrentSession(context.HttpContext); + if (!AdminRoles.CanManageAdminWorkspace(session.Role)) + { + return Results.Json(new { message = "This admin area requires an admin or owner role." }, statusCode: StatusCodes.Status403Forbidden); + } + + return await next(context); + } +} diff --git a/Backend/Endpoints/AdminModerationEndpoints.cs b/Backend/Endpoints/AdminModerationEndpoints.cs new file mode 100644 index 0000000..41140c9 --- /dev/null +++ b/Backend/Endpoints/AdminModerationEndpoints.cs @@ -0,0 +1,18 @@ +namespace Backend.Endpoints; + +public static partial class AdminModerationEndpoints +{ + public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group) + { + group.MapDelete("/clips/{clipId:int}", DeleteClip).WithName("DeleteAdminClip").WithOpenApi(); + group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus).WithName("UpdateAdminClipStatus").WithOpenApi(); + group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination).WithName("ApproveAdminNomination").WithOpenApi(); + group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination).WithName("RejectAdminNomination").WithOpenApi(); + group.MapGet("/risk-flags", GetRiskFlags).WithName("GetAdminRiskFlags").WithOpenApi(); + group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag).WithName("ResolveRiskFlag").WithOpenApi(); + group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags).WithName("BulkResolveRiskFlags").WithOpenApi(); + group.MapGet("/risk-rules", GetRiskRules).WithName("GetAdminRiskRules").WithOpenApi(); + group.MapPut("/risk-rules", UpdateRiskRules).WithName("UpdateAdminRiskRules").WithOpenApi(); + return group; + } +} diff --git a/Backend/Endpoints/AdminNominationModerationEndpoints.cs b/Backend/Endpoints/AdminNominationModerationEndpoints.cs new file mode 100644 index 0000000..eff4b92 --- /dev/null +++ b/Backend/Endpoints/AdminNominationModerationEndpoints.cs @@ -0,0 +1,125 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminModerationEndpoints +{ + private static async Task ApproveNomination( + HttpContext context, + int nominationId, + ApproveNominationRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var nomination = await db.Nominations + .Include(item => item.Category) + .FirstOrDefaultAsync(item => item.Id == nominationId); + + if (nomination is null) + { + return Results.NotFound(); + } + + var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName) + ? nomination.CandidateText + : request.DisplayName.Trim(); + + if (string.IsNullOrWhiteSpace(rawDisplayName)) + { + return Results.BadRequest(new { message = "A display name is required to approve the nomination." }); + } + + var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty; + var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim(); + + var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item => + item.SeasonId == nomination.SeasonId + && item.CategoryId == nomination.CategoryId + && item.DisplayName.ToLower() == rawDisplayName.ToLower()); + + var candidate = existingCandidate; + if (candidate is null) + { + candidate = new Candidate + { + SeasonId = nomination.SeasonId, + CategoryId = nomination.CategoryId, + DisplayName = rawDisplayName, + ChannelSlug = channelSlug, + Platform = platform, + }; + + db.Candidates.Add(candidate); + await db.SaveChangesAsync(context.RequestAborted); + } + else + { + if (!string.IsNullOrWhiteSpace(channelSlug)) + { + candidate.ChannelSlug = channelSlug; + } + + if (!string.IsNullOrWhiteSpace(platform)) + { + candidate.Platform = platform; + } + } + + nomination.CandidateId = candidate.Id; + nomination.Status = "approved"; + nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); + nomination.ReviewedAt = DateTimeOffset.UtcNow; + nomination.ReviewedByTwitchId = session.TwitchUserId; + + adminAuditService.AddEntry( + session.TwitchUserId, + "nomination.approve", + "nomination", + nomination.Id.ToString(), + $"Nominierung {nomination.Id} wurde als Kandidat uebernommen.", + new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null }); + } + + private static async Task RejectNomination( + HttpContext context, + int nominationId, + RejectNominationRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId); + if (nomination is null) + { + return Results.NotFound(); + } + + nomination.CandidateId = null; + nomination.Status = "rejected"; + nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); + nomination.ReviewedAt = DateTimeOffset.UtcNow; + nomination.ReviewedByTwitchId = session.TwitchUserId; + + adminAuditService.AddEntry( + session.TwitchUserId, + "nomination.reject", + "nomination", + nomination.Id.ToString(), + $"Nominierung {nomination.Id} wurde verworfen.", + new { nomination.ReviewNote }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true }); + } +} diff --git a/Backend/Endpoints/AdminRiskFlagEndpoints.cs b/Backend/Endpoints/AdminRiskFlagEndpoints.cs new file mode 100644 index 0000000..832f7c8 --- /dev/null +++ b/Backend/Endpoints/AdminRiskFlagEndpoints.cs @@ -0,0 +1,382 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Security; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminModerationEndpoints +{ + private static async Task GetRiskFlags( + int? limit, + int? offset, + string? status, + string? severity, + string? query, + bool? reviewedOnly, + AwardsDbContext db) + { + var normalizedLimit = Math.Clamp(limit ?? 25, 1, 100); + var normalizedOffset = Math.Max(offset ?? 0, 0); + var normalizedStatus = NormalizeRiskFlagStatus(status, allowAll: true); + if (normalizedStatus is null) + { + return Results.BadRequest(new { message = "Risk flag status must be open, resolved, dismissed or all." }); + } + + var riskQuery = db.RiskFlags.AsNoTracking(); + + if (reviewedOnly == true) + { + riskQuery = riskQuery.Where(item => item.Status != "open"); + } + + if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase)) + { + var normalizedSeverity = severity.Trim().ToLowerInvariant(); + if (normalizedSeverity is not ("low" or "medium" or "high")) + { + return Results.BadRequest(new { message = "Risk flag severity must be low, medium, high or all." }); + } + + riskQuery = riskQuery.Where(item => item.Severity == normalizedSeverity); + } + + if (normalizedStatus != "all") + { + riskQuery = riskQuery.Where(item => item.Status == normalizedStatus); + } + + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = $"%{query.Trim()}%"; + riskQuery = riskQuery.Where(item => + EF.Functions.ILike(item.Source, pattern) || + EF.Functions.ILike(item.Type, pattern) || + EF.Functions.ILike(item.Severity, pattern) || + EF.Functions.ILike(item.Status, pattern) || + EF.Functions.ILike(item.Summary, pattern) || + (item.TwitchUserId != null && EF.Functions.ILike(item.TwitchUserId, pattern)) || + EF.Functions.ILike(item.CreatedFromIp, pattern) || + (item.ReviewNote != null && EF.Functions.ILike(item.ReviewNote, pattern)) || + EF.Functions.ILike(item.MetadataJson, pattern)); + } + + var totalCount = await riskQuery.CountAsync(); + var severityCounts = await riskQuery + .GroupBy(item => item.Severity) + .Select(group => new AdminRiskCountDto(group.Key, group.Count())) + .ToArrayAsync(); + var statusCounts = await riskQuery + .GroupBy(item => item.Status) + .Select(group => new AdminRiskCountDto(group.Key, group.Count())) + .ToArrayAsync(); + + var flags = await riskQuery + .OrderByDescending(item => item.CreatedAt) + .Skip(normalizedOffset) + .Take(normalizedLimit) + .ToArrayAsync(); + + var items = flags.Select(AdminRiskFlagMappings.ToDto).ToArray(); + + return Results.Ok(new AdminRiskFlagsResponse( + items, + totalCount, + items.Length, + normalizedOffset, + normalizedLimit, + normalizedOffset + items.Length < totalCount, + severityCounts, + statusCounts)); + } + + private static async Task ResolveRiskFlag( + HttpContext context, + int riskFlagId, + ResolveRiskFlagRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId); + if (riskFlag is null) + { + return Results.NotFound(); + } + + var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false); + if (normalizedStatus is null) + { + return Results.BadRequest(new { message = "Risk flag status must be open, resolved or dismissed." }); + } + + var previousStatus = riskFlag.Status; + var previousReviewNote = riskFlag.ReviewNote; + var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote); + + riskFlag.Status = normalizedStatus; + if (normalizedStatus == "open") + { + riskFlag.ReviewedAt = null; + riskFlag.ReviewedByTwitchId = null; + riskFlag.ReviewNote = null; + } + else + { + riskFlag.ReviewedAt = DateTimeOffset.UtcNow; + riskFlag.ReviewedByTwitchId = session.TwitchUserId; + riskFlag.ReviewNote = normalizedReviewNote; + } + + adminAuditService.AddEntry( + session.TwitchUserId, + "risk.resolve", + "risk-flag", + riskFlag.Id.ToString(), + $"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.", + new + { + riskFlag.Type, + riskFlag.Source, + changes = BuildRiskResolutionChanges(previousStatus, riskFlag.Status, previousReviewNote, riskFlag.ReviewNote), + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status }); + } + + private static async Task BulkResolveRiskFlags( + HttpContext context, + BulkResolveRiskFlagsRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var normalizedIds = request.RiskFlagIds + .Distinct() + .Take(100) + .ToArray(); + if (normalizedIds.Length == 0) + { + return Results.BadRequest(new { message = "Bitte waehle mindestens einen Risikohinweis aus." }); + } + + var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false); + if (normalizedStatus is null || normalizedStatus == "open") + { + return Results.BadRequest(new { message = "Bulk-Verarbeitung unterstuetzt erledigt oder verworfen." }); + } + + var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote); + if (string.IsNullOrWhiteSpace(normalizedReviewNote)) + { + return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht eine Review-Notiz." }); + } + + var riskFlags = await db.RiskFlags + .Where(item => normalizedIds.Contains(item.Id)) + .ToArrayAsync(context.RequestAborted); + + if (riskFlags.Length != normalizedIds.Length) + { + return Results.BadRequest(new { message = "Mindestens ein Risikohinweis wurde nicht gefunden." }); + } + + if (riskFlags.Any(item => item.Severity != "low")) + { + return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer Low-Severity-Hinweise erlaubt." }); + } + + if (riskFlags.Any(item => item.Status != "open")) + { + return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Hinweise erlaubt." }); + } + + var reviewedAt = DateTimeOffset.UtcNow; + foreach (var riskFlag in riskFlags) + { + riskFlag.Status = normalizedStatus; + riskFlag.ReviewNote = normalizedReviewNote; + riskFlag.ReviewedAt = reviewedAt; + riskFlag.ReviewedByTwitchId = session.TwitchUserId; + } + + adminAuditService.AddEntry( + session.TwitchUserId, + "risk.bulk-resolve", + "risk-flag", + string.Join(",", normalizedIds), + $"{normalizedIds.Length} Low-Risk Flags wurden als {normalizedStatus} markiert.", + new + { + status = normalizedStatus, + count = normalizedIds.Length, + riskFlagIds = normalizedIds, + changes = new[] + { + new + { + field = "status", + label = "Status", + from = "open", + to = normalizedStatus, + sensitive = false, + }, + new + { + field = "reviewNote", + label = "Review-Notiz", + from = "keine Notiz", + to = "Notiz vorhanden", + sensitive = true, + }, + }, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, count = normalizedIds.Length, status = normalizedStatus }); + } + + private static async Task GetRiskRules(AwardsDbContext db) + { + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + return Results.Ok(new AdminRiskRulesResponse(RiskRuleSettings.Read(settings).Select(ToRiskRuleDto).ToArray())); + } + + private static async Task UpdateRiskRules( + HttpContext context, + UpdateRiskRulesRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + if (!AdminRoles.CanManageAdminWorkspace(session.Role)) + { + return Results.Json(new { message = "Risk-Regeln koennen nur Admins oder Owner aendern." }, statusCode: StatusCodes.Status403Forbidden); + } + + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + var before = RiskRuleSettings.Read(settings); + var mergedRules = RiskRuleSettings.Defaults + .Select(defaultRule => + { + var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key); + return requestRule is null + ? defaultRule + : new RiskRuleSetting( + defaultRule.Key, + defaultRule.Label, + requestRule.Enabled, + requestRule.Threshold, + requestRule.WindowMinutes, + requestRule.Severity, + defaultRule.Description); + }) + .ToArray(); + + settings.RiskRulesJson = RiskRuleSettings.Serialize(mergedRules); + var after = RiskRuleSettings.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.Threshold}/{previous.WindowMinutes}/{previous.Severity}", + to = $"{rule.Enabled}/{rule.Threshold}/{rule.WindowMinutes}/{rule.Severity}", + sensitive = false, + }; + }) + .Where(change => change.from != change.to) + .ToArray(); + + adminAuditService.AddEntry( + session.TwitchUserId, + "risk-rules.update", + "site-settings", + settings.Id.ToString(), + "Risk-Regeln wurden aktualisiert.", + new { changes }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new AdminRiskRulesResponse(after.Select(ToRiskRuleDto).ToArray())); + } + + private static AdminRiskRuleDto ToRiskRuleDto(RiskRuleSetting rule) => + new(rule.Key, rule.Label, rule.Enabled, rule.Threshold, rule.WindowMinutes, rule.Severity, rule.Description); + + private static string? NormalizeReviewNote(string? reviewNote) + { + if (string.IsNullOrWhiteSpace(reviewNote)) + { + return null; + } + + var trimmedReviewNote = reviewNote.Trim(); + return trimmedReviewNote.Length <= 500 ? trimmedReviewNote : trimmedReviewNote[..500]; + } + + private static object[] BuildRiskResolutionChanges( + string previousStatus, + string currentStatus, + string? previousReviewNote, + string? currentReviewNote) + { + var changes = new List + { + new + { + field = "status", + label = "Status", + from = previousStatus, + to = currentStatus, + sensitive = false, + }, + }; + + if (!string.Equals(previousReviewNote, currentReviewNote, StringComparison.Ordinal)) + { + changes.Add(new + { + field = "reviewNote", + label = "Review-Notiz", + from = string.IsNullOrWhiteSpace(previousReviewNote) ? "keine Notiz" : "Notiz vorhanden", + to = string.IsNullOrWhiteSpace(currentReviewNote) ? "keine Notiz" : "Notiz vorhanden", + sensitive = true, + }); + } + + return changes.ToArray(); + } + + private static string? NormalizeRiskFlagStatus(string? status, bool allowAll) + { + var normalizedStatus = string.IsNullOrWhiteSpace(status) ? "open" : status.Trim().ToLowerInvariant(); + return normalizedStatus switch + { + "open" or "resolved" or "dismissed" => normalizedStatus, + "all" when allowAll => normalizedStatus, + _ => null, + }; + } +} diff --git a/Backend/Endpoints/AdminRiskFlagMappings.cs b/Backend/Endpoints/AdminRiskFlagMappings.cs new file mode 100644 index 0000000..85abf15 --- /dev/null +++ b/Backend/Endpoints/AdminRiskFlagMappings.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using Backend.Contracts; +using Backend.Domain; + +namespace Backend.Endpoints; + +public static class AdminRiskFlagMappings +{ + public static AdminRiskFlagDto ToDto(RiskFlag item) => + new( + item.Id, + item.Source, + item.Type, + item.Severity, + item.Status, + item.Summary, + item.TwitchUserId, + item.CreatedFromIp, + item.CreatedAt, + item.MetadataJson, + item.ReviewNote, + item.ReviewedByTwitchId, + item.ReviewedAt, + BuildEntityLinks(item)); + + public static AdminRiskEntityLinkDto[] BuildEntityLinks(RiskFlag item) + { + var links = ReadExplicitLinks(item.MetadataJson).ToList(); + if (links.Count > 0) + { + return links.ToArray(); + } + + var query = Uri.EscapeDataString(item.TwitchUserId ?? item.Summary); + return item.Source.ToLowerInvariant() switch + { + "clip" => [new AdminRiskEntityLinkDto("Clips öffnen", "clip", item.TwitchUserId ?? item.Id.ToString(), $"/admin/clips?query={query}")], + "nomination" => [new AdminRiskEntityLinkDto("Reviews öffnen", "nomination", item.TwitchUserId ?? item.Id.ToString(), $"/admin/reviews?query={query}")], + "vote" => [new AdminRiskEntityLinkDto("Voting-Analytics öffnen", "vote", item.TwitchUserId ?? item.Id.ToString(), $"/admin/analytics?query={query}")], + "login" => [new AdminRiskEntityLinkDto("Audit-Log öffnen", "session", item.TwitchUserId ?? item.Id.ToString(), $"/admin/users-logs?query={query}")], + _ => [], + }; + } + + private static IEnumerable ReadExplicitLinks(string metadataJson) + { + if (string.IsNullOrWhiteSpace(metadataJson)) + { + yield break; + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(metadataJson); + } + catch (JsonException) + { + yield break; + } + + using (document) + { + if (TryReadEntityLinks(document.RootElement, out var entityLinks)) + { + foreach (var link in entityLinks) + { + yield return link; + } + } + } + } + + private static bool TryReadEntityLinks(JsonElement root, out AdminRiskEntityLinkDto[] links) + { + links = []; + if (root.ValueKind != JsonValueKind.Object || !TryGetProperty(root, "entityLinks", out var entityLinks) || entityLinks.ValueKind != JsonValueKind.Array) + { + return false; + } + + links = entityLinks.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.Object) + .Select(item => new AdminRiskEntityLinkDto( + ReadString(item, "label"), + ReadString(item, "entityType"), + ReadString(item, "entityId"), + ReadString(item, "to"))) + .Where(item => !string.IsNullOrWhiteSpace(item.Label) && !string.IsNullOrWhiteSpace(item.To)) + .ToArray(); + + return links.Length > 0; + } + + private static bool TryGetProperty(JsonElement root, string propertyName, out JsonElement value) + { + foreach (var property in root.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } + + private static string ReadString(JsonElement root, string propertyName) + { + if (!TryGetProperty(root, propertyName, out var value)) + { + return string.Empty; + } + + return value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? string.Empty, + JsonValueKind.Number => value.GetRawText(), + _ => string.Empty, + }; + } +} diff --git a/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs new file mode 100644 index 0000000..dc3c3d6 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs @@ -0,0 +1,149 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task CreateCandidate( + HttpContext context, + int seasonId, + UpsertCandidateRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var validationError = ValidateCandidateRequest(request); + if (validationError is not null) + { + return validationError; + } + + var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId); + if (category is null) + { + return Results.BadRequest(new { message = "The selected category does not exist in this season." }); + } + + var normalizedDisplayName = request.DisplayName.Trim(); + var normalizedChannelSlug = request.ChannelSlug.Trim(); + if (await db.Candidates.AnyAsync(item => + item.SeasonId == seasonId + && item.CategoryId == request.CategoryId + && (item.DisplayName.ToLower() == normalizedDisplayName.ToLower() + || item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower()))) + { + return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." }); + } + + var candidate = new Candidate + { + SeasonId = seasonId, + CategoryId = request.CategoryId, + DisplayName = normalizedDisplayName, + ChannelSlug = normalizedChannelSlug, + Platform = request.Platform.Trim(), + }; + + db.Candidates.Add(candidate); + adminAuditService.AddEntry( + session.TwitchUserId, + "candidate.create", + "candidate", + request.DisplayName.Trim(), + $"Kandidat {request.DisplayName.Trim()} wurde angelegt.", + new { seasonId, request.CategoryId, request.Platform }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, candidateId = candidate.Id }); + } + + private static async Task UpdateCandidate( + HttpContext context, + int candidateId, + UpsertCandidateRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var validationError = ValidateCandidateRequest(request); + if (validationError is not null) + { + return validationError; + } + + var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId); + if (candidate is null) + { + return Results.NotFound(); + } + + var targetCategory = await db.Categories.FirstOrDefaultAsync(item => + item.Id == request.CategoryId && item.SeasonId == candidate.SeasonId); + if (targetCategory is null) + { + return Results.BadRequest(new { message = "The selected category does not exist in this season." }); + } + + var normalizedDisplayName = request.DisplayName.Trim(); + var normalizedChannelSlug = request.ChannelSlug.Trim(); + if (await db.Candidates.AnyAsync(item => + item.SeasonId == candidate.SeasonId + && item.CategoryId == request.CategoryId + && item.Id != candidateId + && (item.DisplayName.ToLower() == normalizedDisplayName.ToLower() + || item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower()))) + { + return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." }); + } + + candidate.CategoryId = request.CategoryId; + candidate.DisplayName = normalizedDisplayName; + candidate.ChannelSlug = normalizedChannelSlug; + candidate.Platform = request.Platform.Trim(); + + adminAuditService.AddEntry( + session.TwitchUserId, + "candidate.update", + "candidate", + candidate.Id.ToString(), + $"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.", + new { request.CategoryId, request.Platform }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, candidateId = candidate.Id }); + } + + private static async Task DeleteCandidate( + HttpContext context, + int candidateId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId); + if (candidate is null) + { + return Results.NotFound(); + } + + db.Candidates.Remove(candidate); + adminAuditService.AddEntry( + session.TwitchUserId, + "candidate.delete", + "candidate", + candidate.Id.ToString(), + $"Kandidat {candidate.DisplayName} wurde gelöscht.", + new { candidate.CategoryId, candidate.Platform }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, candidateId }); + } +} diff --git a/Backend/Endpoints/AdminSeasonCategoryEndpoints.cs b/Backend/Endpoints/AdminSeasonCategoryEndpoints.cs new file mode 100644 index 0000000..a2df6c6 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonCategoryEndpoints.cs @@ -0,0 +1,146 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task CreateCategory( + HttpContext context, + int seasonId, + UpsertCategoryRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var validationError = ValidateCategoryRequest(request); + if (validationError is not null) + { + return validationError; + } + + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId); + if (season is null) + { + return Results.NotFound(); + } + + var normalizedSlug = request.Slug.Trim(); + if (await db.Categories.AnyAsync(item => + item.SeasonId == seasonId + && item.Slug.ToLower() == normalizedSlug.ToLower())) + { + return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." }); + } + + var category = new Category + { + SeasonId = seasonId, + GroupName = request.GroupName.Trim(), + Name = request.Name.Trim(), + Slug = normalizedSlug, + Description = request.Description.Trim(), + SortOrder = request.SortOrder, + MaxNomineesPerUser = request.MaxNomineesPerUser, + }; + + db.Categories.Add(category); + adminAuditService.AddEntry( + session.TwitchUserId, + "category.create", + "category", + request.Slug.Trim(), + $"Kategorie {request.Name.Trim()} wurde angelegt.", + new { seasonId, request.GroupName, request.SortOrder }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, categoryId = category.Id }); + } + + private static async Task UpdateCategory( + HttpContext context, + int categoryId, + UpsertCategoryRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var validationError = ValidateCategoryRequest(request); + if (validationError is not null) + { + return validationError; + } + + var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId); + if (category is null) + { + return Results.NotFound(); + } + + var normalizedSlug = request.Slug.Trim(); + if (await db.Categories.AnyAsync(item => + item.SeasonId == category.SeasonId + && item.Id != categoryId + && item.Slug.ToLower() == normalizedSlug.ToLower())) + { + return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." }); + } + + category.GroupName = request.GroupName.Trim(); + category.Name = request.Name.Trim(); + category.Slug = normalizedSlug; + category.Description = request.Description.Trim(); + category.SortOrder = request.SortOrder; + category.MaxNomineesPerUser = request.MaxNomineesPerUser; + + adminAuditService.AddEntry( + session.TwitchUserId, + "category.update", + "category", + category.Id.ToString(), + $"Kategorie {request.Name.Trim()} wurde aktualisiert.", + new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, categoryId = category.Id }); + } + + private static async Task DeleteCategory( + HttpContext context, + int categoryId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId); + if (category is null) + { + return Results.NotFound(); + } + + var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync(); + if (candidates.Length > 0) + { + db.Candidates.RemoveRange(candidates); + } + + db.Categories.Remove(category); + adminAuditService.AddEntry( + session.TwitchUserId, + "category.delete", + "category", + category.Id.ToString(), + $"Kategorie {category.Name} wurde gelöscht.", + new { removedCandidates = candidates.Length }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, categoryId }); + } +} diff --git a/Backend/Endpoints/AdminSeasonCreateEndpoints.cs b/Backend/Endpoints/AdminSeasonCreateEndpoints.cs new file mode 100644 index 0000000..1428e2e --- /dev/null +++ b/Backend/Endpoints/AdminSeasonCreateEndpoints.cs @@ -0,0 +1,125 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task CreateSeason( + HttpContext context, + CreateSeasonRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var validationError = ValidateSeasonRequest(request); + if (validationError is not null) + { + return validationError; + } + + if (await db.Seasons.AnyAsync(item => item.Year == request.Year)) + { + return Results.BadRequest(new { message = $"A season for {request.Year} already exists." }); + } + + var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl); + + var season = new Season + { + Year = request.Year, + Name = request.Name.Trim(), + ShowStreamUrl = showStreamUrl, + CurrentPhase = request.CurrentPhase.Trim(), + IsCurrent = request.IsCurrent, + IsCommunityOnly = request.IsCommunityOnly, + NominationStartsAt = request.NominationStartsAt, + NominationEndsAt = request.NominationEndsAt, + VotingStartsAt = request.VotingStartsAt, + VotingEndsAt = request.VotingEndsAt, + ReviewStartsAt = request.ReviewStartsAt, + ReviewEndsAt = request.ReviewEndsAt, + ShowDate = request.ShowDate, + ShowStartsAt = request.ShowStartsAt, + }; + + db.Seasons.Add(season); + var copiedCategoryCount = 0; + if (request.CopyStructureFromSeasonId is { } sourceSeasonId) + { + if (sourceSeasonId <= 0) + { + return Results.BadRequest(new { message = "A valid source season is required for copying structure." }); + } + + var sourceSeasonExists = await db.Seasons + .AsNoTracking() + .AnyAsync(item => item.Id == sourceSeasonId, context.RequestAborted); + if (!sourceSeasonExists) + { + return Results.BadRequest(new { message = "Source season for structure copy was not found." }); + } + + var sourceCategories = await db.Categories + .AsNoTracking() + .Where(item => item.SeasonId == sourceSeasonId) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .ToArrayAsync(context.RequestAborted); + + copiedCategoryCount = sourceCategories.Length; + foreach (var category in sourceCategories) + { + db.Categories.Add(new Category + { + Season = season, + GroupName = category.GroupName, + Name = category.Name, + Slug = category.Slug, + Description = category.Description, + SortOrder = category.SortOrder, + MaxNomineesPerUser = category.MaxNomineesPerUser, + }); + } + } + + var readinessIssues = BuildNewSeasonReadinessIssues( + request.CurrentPhase, + request.IsCurrent, + copiedCategoryCount); + if (readinessIssues.Length > 0) + { + return CreateReadinessError(readinessIssues); + } + + await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent, null, context.RequestAborted); + + adminAuditService.AddEntry( + session.TwitchUserId, + "season.create", + "season", + request.Year.ToString(), + copiedCategoryCount > 0 + ? $"Season {request.Year} wurde angelegt und {copiedCategoryCount} Kategorien wurden kopiert." + : $"Season {request.Year} wurde angelegt.", + new + { + request.IsCurrent, + request.IsCommunityOnly, + showStreamUrl, + request.CurrentPhase, + request.ShowDate, + request.ShowStartsAt, + request.CopyStructureFromSeasonId, + copiedCategoryCount, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, seasonId = season.Id, copiedCategoryCount }); + } +} diff --git a/Backend/Endpoints/AdminSeasonDeleteEndpoints.cs b/Backend/Endpoints/AdminSeasonDeleteEndpoints.cs new file mode 100644 index 0000000..72df5bd --- /dev/null +++ b/Backend/Endpoints/AdminSeasonDeleteEndpoints.cs @@ -0,0 +1,71 @@ +using Backend.Common; +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task DeleteSeason( + HttpContext context, + int seasonId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId); + if (season is null) + { + return Results.NotFound(); + } + + if (season.IsCurrent) + { + return Results.BadRequest(new { message = "Das öffentlich aktive Award-Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich." }); + } + + await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted); + + var deletionSummary = await DeleteSeasonRelationsAsync(db, seasonId, context.RequestAborted); + + db.Seasons.Remove(season); + adminAuditService.AddEntry( + session.TwitchUserId, + "season.delete", + "season", + season.Id.ToString(), + $"Season {season.Year} wurde gelöscht.", + new + { + season.Year, + deletionSummary.DeletedVoteEntries, + deletionSummary.DeletedBallots, + deletionSummary.DeletedResults, + deletionSummary.DeletedNominations, + deletionSummary.DeletedClips, + deletionSummary.DeletedRiskFlags, + deletionSummary.DeletedCandidates, + deletionSummary.DeletedCategories, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + await transaction.CommitAsync(context.RequestAborted); + + return Results.Ok(new + { + deleted = true, + seasonId, + season.Year, + deletedVoteEntries = deletionSummary.DeletedVoteEntries, + deletedBallots = deletionSummary.DeletedBallots, + deletedResults = deletionSummary.DeletedResults, + deletedNominations = deletionSummary.DeletedNominations, + deletedClips = deletionSummary.DeletedClips, + deletedRiskFlags = deletionSummary.DeletedRiskFlags, + deletedCandidates = deletionSummary.DeletedCandidates, + deletedCategories = deletionSummary.DeletedCategories, + }); + } +} diff --git a/Backend/Endpoints/AdminSeasonDeletionSupport.cs b/Backend/Endpoints/AdminSeasonDeletionSupport.cs new file mode 100644 index 0000000..693a635 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonDeletionSupport.cs @@ -0,0 +1,65 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private sealed record SeasonDeletionSummary( + int DeletedVoteEntries, + int DeletedBallots, + int DeletedResults, + int DeletedNominations, + int DeletedClips, + int DeletedRiskFlags, + int DeletedCandidates, + int DeletedCategories); + + private static async Task DeleteSeasonRelationsAsync( + AwardsDbContext db, + int seasonId, + CancellationToken cancellationToken) + { + var ballotIds = await db.VoteBallots + .Where(item => item.SeasonId == seasonId) + .Select(item => item.Id) + .ToArrayAsync(cancellationToken); + + var deletedVoteEntries = ballotIds.Length == 0 + ? 0 + : await db.VoteEntries + .Where(item => ballotIds.Contains(item.BallotId)) + .ExecuteDeleteAsync(cancellationToken); + var deletedBallots = await db.VoteBallots + .Where(item => item.SeasonId == seasonId) + .ExecuteDeleteAsync(cancellationToken); + var deletedResults = await db.Results + .Where(item => item.SeasonId == seasonId) + .ExecuteDeleteAsync(cancellationToken); + var deletedNominations = await db.Nominations + .Where(item => item.SeasonId == seasonId) + .ExecuteDeleteAsync(cancellationToken); + var deletedClips = await db.ClipSubmissions + .Where(item => item.SeasonId == seasonId) + .ExecuteDeleteAsync(cancellationToken); + var deletedRiskFlags = await db.RiskFlags + .Where(item => item.SeasonId == seasonId) + .ExecuteDeleteAsync(cancellationToken); + var deletedCandidates = await db.Candidates + .Where(item => item.SeasonId == seasonId) + .ExecuteDeleteAsync(cancellationToken); + var deletedCategories = await db.Categories + .Where(item => item.SeasonId == seasonId) + .ExecuteDeleteAsync(cancellationToken); + + return new SeasonDeletionSummary( + deletedVoteEntries, + deletedBallots, + deletedResults, + deletedNominations, + deletedClips, + deletedRiskFlags, + deletedCandidates, + deletedCategories); + } +} diff --git a/Backend/Endpoints/AdminSeasonDetailEndpoints.cs b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs new file mode 100644 index 0000000..e606c03 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs @@ -0,0 +1,163 @@ +using Backend.Contracts; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task GetSeasonDetail(int seasonId, AwardsDbContext db) + { + var season = await db.Seasons + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == seasonId); + + if (season is null) + { + return Results.NotFound(); + } + + var candidates = await db.Candidates + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.DisplayName) + .Select(item => new AdminCandidateItemDto( + item.Id, + item.CategoryId, + item.DisplayName, + item.ChannelSlug, + item.Platform)) + .ToArrayAsync(); + + var candidateCounts = candidates + .GroupBy(item => item.CategoryId) + .ToDictionary(grouping => grouping.Key, grouping => grouping.Count()); + + var categoryRows = await db.Categories + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Select(category => new + { + category.Id, + category.GroupName, + category.Name, + category.Slug, + category.Description, + category.SortOrder, + category.MaxNomineesPerUser, + }) + .ToArrayAsync(); + + var categories = categoryRows + .Select(category => new AdminCategoryItemDto( + category.Id, + category.GroupName, + category.Name, + category.Slug, + category.Description, + category.SortOrder, + category.MaxNomineesPerUser, + candidateCounts.TryGetValue(category.Id, out var count) ? count : 0)) + .ToArray(); + + var pendingNominations = await db.Nominations + .AsNoTracking() + .Where(item => item.SeasonId == seasonId && item.Status == "pending" && item.CandidateText != null) + .OrderByDescending(item => item.CreatedAt) + .Select(item => new AdminNominationReviewItemDto( + item.Id, + item.CategoryId, + item.Category.Name, + item.SubmittedByTwitchId, + item.CandidateText!, + item.StreamUrl, + item.Status, + item.CreatedAt, + item.CandidateId, + item.CandidateId != null ? item.Candidate!.DisplayName : null, + item.ReviewNote, + item.ReviewedByTwitchId, + item.ReviewedAt)) + .ToArrayAsync(); + + var reviewedNominations = await db.Nominations + .AsNoTracking() + .Where(item => item.SeasonId == seasonId && item.Status != "pending") + .OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt) + .Select(item => new AdminNominationReviewItemDto( + item.Id, + item.CategoryId, + item.Category.Name, + item.SubmittedByTwitchId, + item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty), + item.StreamUrl, + item.Status, + item.CreatedAt, + item.CandidateId, + item.CandidateId != null ? item.Candidate!.DisplayName : null, + item.ReviewNote, + item.ReviewedByTwitchId, + item.ReviewedAt)) + .ToArrayAsync(); + + var resultItems = await db.Results + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.Category.SortOrder) + .ThenBy(item => item.Category.Name) + .Select(item => new AdminAwardResultItemDto( + item.Id, + item.CategoryId, + item.Category.Name, + item.CandidateId, + item.Candidate.DisplayName, + item.Candidate.ChannelSlug, + item.Candidate.Platform)) + .ToArrayAsync(); + + var clipSubmissions = await db.ClipSubmissions + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .OrderByDescending(item => item.CreatedAt) + .Select(item => new AdminClipSubmissionItemDto( + item.Id, + item.CategoryId, + item.CandidateId, + item.SubmittedByTwitchId, + item.ClipUrl, + item.Title, + item.Creator, + item.Platform, + item.Status, + item.CreatedAt, + item.ReviewNote, + item.ReviewedByTwitchId, + item.ReviewedAt)) + .ToArrayAsync(); + + return Results.Ok(new AdminSeasonDetailResponse( + season.Id, + season.Year, + season.Name, + NormalizeSeasonStreamUrl(season.ShowStreamUrl), + season.CurrentPhase, + season.IsCurrent, + season.IsCommunityOnly, + season.NominationStartsAt, + season.NominationEndsAt, + season.VotingStartsAt, + season.VotingEndsAt, + season.ReviewStartsAt, + season.ReviewEndsAt, + season.ShowDate, + season.ShowStartsAt, + categories, + candidates, + pendingNominations, + reviewedNominations, + resultItems, + clipSubmissions)); + } +} diff --git a/Backend/Endpoints/AdminSeasonListEndpoints.cs b/Backend/Endpoints/AdminSeasonListEndpoints.cs new file mode 100644 index 0000000..91b0a79 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonListEndpoints.cs @@ -0,0 +1,25 @@ +using Backend.Contracts; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task GetSeasons(AwardsDbContext db) + { + var seasons = await db.Seasons + .AsNoTracking() + .OrderByDescending(item => item.Year) + .Select(item => new AdminSeasonListItemDto( + item.Id, + item.Year, + item.Name, + item.CurrentPhase, + item.IsCurrent, + item.Categories.Count)) + .ToArrayAsync(); + + return Results.Ok(seasons); + } +} diff --git a/Backend/Endpoints/AdminSeasonManagementEndpoints.cs b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs new file mode 100644 index 0000000..c98ce03 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs @@ -0,0 +1,22 @@ +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/seasons", GetSeasons).WithName("GetAdminSeasons").WithOpenApi(); + group.MapPost("/seasons", CreateSeason).WithName("CreateAdminSeason").WithOpenApi(); + group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail).WithName("GetAdminSeasonDetail").WithOpenApi(); + group.MapPut("/seasons/{seasonId:int}", UpdateSeason).WithName("UpdateAdminSeason").WithOpenApi(); + group.MapDelete("/seasons/{seasonId:int}", DeleteSeason).WithName("DeleteAdminSeason").WithOpenApi(); + group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory).WithName("CreateAdminCategory").WithOpenApi(); + group.MapPut("/categories/{categoryId:int}", UpdateCategory).WithName("UpdateAdminCategory").WithOpenApi(); + group.MapDelete("/categories/{categoryId:int}", DeleteCategory).WithName("DeleteAdminCategory").WithOpenApi(); + group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate).WithName("CreateAdminCandidate").WithOpenApi(); + group.MapPut("/candidates/{candidateId:int}", UpdateCandidate).WithName("UpdateAdminCandidate").WithOpenApi(); + group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate).WithName("DeleteAdminCandidate").WithOpenApi(); + group.MapPost("/seasons/{seasonId:int}/results", SetResult).WithName("SetAdminResult").WithOpenApi(); + group.MapDelete("/results/{resultId:int}", DeleteResult).WithName("DeleteAdminResult").WithOpenApi(); + return group; + } +} diff --git a/Backend/Endpoints/AdminSeasonManagementSupport.cs b/Backend/Endpoints/AdminSeasonManagementSupport.cs new file mode 100644 index 0000000..0b23fef --- /dev/null +++ b/Backend/Endpoints/AdminSeasonManagementSupport.cs @@ -0,0 +1,313 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private const int MaxCategoryGroupNameLength = 80; + private const int MaxCategoryNameLength = 120; + private const int MaxCategorySlugLength = 120; + private const int MaxCategoryDescriptionLength = 600; + private const int MaxCandidateDisplayNameLength = 120; + private const int MaxCandidateChannelSlugLength = 120; + private const int MaxCandidatePlatformLength = 60; + + private static IResult? ValidateSeasonRequest(CreateSeasonRequest request) + { + if (request.Year < 2020 || request.Year > 2100) + { + return Results.BadRequest(new { message = "Please provide a valid award year." }); + } + + if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase)) + { + return Results.BadRequest(new { message = "Season name and current phase are required." }); + } + + if (!IsKnownSeasonPhase(request.CurrentPhase)) + { + return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." }); + } + + if (!SeasonMappings.IsSeasonScheduleValid( + request.NominationStartsAt, + request.NominationEndsAt, + request.VotingStartsAt, + request.VotingEndsAt, + request.ReviewStartsAt, + request.ReviewEndsAt, + request.ShowDate)) + { + return Results.BadRequest(new { message = "The season schedule is not in chronological order." }); + } + + return null; + } + + private static IResult? ValidateSeasonRequest(UpdateSeasonRequest request) + { + if (request.Year < 2020 || request.Year > 2100) + { + return Results.BadRequest(new { message = "Please provide a valid award year." }); + } + + if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase)) + { + return Results.BadRequest(new { message = "Season name and current phase are required." }); + } + + if (!IsKnownSeasonPhase(request.CurrentPhase)) + { + return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." }); + } + + if (!SeasonMappings.IsSeasonScheduleValid( + request.NominationStartsAt, + request.NominationEndsAt, + request.VotingStartsAt, + request.VotingEndsAt, + request.ReviewStartsAt, + request.ReviewEndsAt, + request.ShowDate)) + { + return Results.BadRequest(new { message = "The season schedule is not in chronological order." }); + } + + return null; + } + + private static string NormalizeSeasonStreamUrl(string? showStreamUrl) + { + return SeasonMappings.NormalizeSeasonStreamUrl(showStreamUrl); + } + + private static bool IsKnownSeasonPhase(string? currentPhase) + { + var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty; + return value.Contains("show") + || value.Contains("abgeschlossen") + || value.Contains("archiv") + || value.Contains("complete") + || value.Contains("ended") + || value.Contains("review") + || value.Contains("auswert") + || value.Contains("vot") + || value.Contains("nomin"); + } + + private static async Task UnsetOtherCurrentSeasonsAsync( + AwardsDbContext db, + bool shouldUnsetOthers, + int? seasonIdToKeep, + CancellationToken cancellationToken) + { + if (!shouldUnsetOthers) + { + return; + } + + var activeSeasons = await db.Seasons + .Where(item => item.IsCurrent && (!seasonIdToKeep.HasValue || item.Id != seasonIdToKeep.Value)) + .ToListAsync(cancellationToken); + + foreach (var activeSeason in activeSeasons) + { + activeSeason.IsCurrent = false; + } + } + + private static IResult CreateReadinessError(IEnumerable issues) + { + var issueList = issues.ToArray(); + return Results.BadRequest(new + { + message = $"Public-/Archiv-Readiness blockiert: {string.Join(" ", issueList)}", + issues = issueList, + }); + } + + private static string[] BuildNewSeasonReadinessIssues( + string currentPhase, + bool isCurrent, + int copiedCategoryCount) + { + var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase); + var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent); + var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey); + if (!isCurrent && !needsWinnerReadiness) + { + return []; + } + + var issues = new List(); + if (copiedCategoryCount <= 0) + { + issues.Add("Mindestens eine Kategorie ist erforderlich."); + } + + if (needsCandidateReadiness) + { + issues.Add("Kandidaten muessen vor dieser Phase fuer alle Kategorien gepflegt sein."); + } + + if (needsWinnerReadiness) + { + issues.Add("Abgeschlossen ist erst moeglich, wenn jede Kategorie einen Gewinner hat."); + } + + return issues.ToArray(); + } + + private static async Task BuildSeasonReadinessIssuesAsync( + AwardsDbContext db, + int seasonId, + string currentPhase, + bool isCurrent, + CancellationToken cancellationToken) + { + var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase); + var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent); + var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey); + if (!isCurrent && !needsWinnerReadiness) + { + return []; + } + + var categoryIds = await db.Categories + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .Select(item => item.Id) + .ToArrayAsync(cancellationToken); + + var issues = new List(); + if (categoryIds.Length == 0) + { + issues.Add("Mindestens eine Kategorie ist erforderlich."); + } + + if (needsCandidateReadiness && categoryIds.Length > 0) + { + var categoriesWithCandidates = await db.Candidates + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .Select(item => item.CategoryId) + .Distinct() + .CountAsync(cancellationToken); + var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates); + if (emptyCategories > 0) + { + issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten."); + } + } + + if (needsWinnerReadiness && categoryIds.Length > 0) + { + var categoriesWithResults = await db.Results + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .Select(item => item.CategoryId) + .Distinct() + .CountAsync(cancellationToken); + var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults); + if (missingResults > 0) + { + issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner."); + } + } + + return issues.ToArray(); + } + + private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent) + { + return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal)) + || string.Equals(phaseKey, "completed", StringComparison.Ordinal); + } + + private static bool RequiresWinnerReadiness(string phaseKey) + { + return string.Equals(phaseKey, "completed", StringComparison.Ordinal); + } + + private static IResult? ValidateCategoryRequest(UpsertCategoryRequest request) + { + var groupName = request.GroupName.Trim(); + var name = request.Name.Trim(); + var slug = request.Slug.Trim(); + var description = request.Description.Trim(); + + if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > MaxCategoryGroupNameLength) + { + return Results.BadRequest(new { message = $"Category group name is required and must stay below {MaxCategoryGroupNameLength} characters." }); + } + + if (string.IsNullOrWhiteSpace(name) || name.Length > MaxCategoryNameLength) + { + return Results.BadRequest(new { message = $"Category name is required and must stay below {MaxCategoryNameLength} characters." }); + } + + if (string.IsNullOrWhiteSpace(slug) || slug.Length > MaxCategorySlugLength) + { + return Results.BadRequest(new { message = $"Category slug is required and must stay below {MaxCategorySlugLength} characters." }); + } + + if (!slug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_')) + { + return Results.BadRequest(new { message = "Category slug contains unsupported characters." }); + } + + if (description.Length > MaxCategoryDescriptionLength) + { + return Results.BadRequest(new { message = $"Category description must stay below {MaxCategoryDescriptionLength} characters." }); + } + + if (request.SortOrder is < 0 or > 500) + { + return Results.BadRequest(new { message = "Category sort order must be between 0 and 500." }); + } + + if (request.MaxNomineesPerUser is < 1 or > 10) + { + return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." }); + } + + return null; + } + + private static IResult? ValidateCandidateRequest(UpsertCandidateRequest request) + { + var displayName = request.DisplayName.Trim(); + var channelSlug = request.ChannelSlug.Trim(); + var platform = request.Platform.Trim(); + + if (request.CategoryId <= 0) + { + return Results.BadRequest(new { message = "A valid category is required." }); + } + + if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > MaxCandidateDisplayNameLength) + { + return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxCandidateDisplayNameLength} characters." }); + } + + if (string.IsNullOrWhiteSpace(channelSlug) || channelSlug.Length > MaxCandidateChannelSlugLength) + { + return Results.BadRequest(new { message = $"Channel slug is required and must stay below {MaxCandidateChannelSlugLength} characters." }); + } + + if (!channelSlug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_' or '.')) + { + return Results.BadRequest(new { message = "Channel slug contains unsupported characters." }); + } + + if (string.IsNullOrWhiteSpace(platform) || platform.Length > MaxCandidatePlatformLength) + { + return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." }); + } + + return null; + } +} diff --git a/Backend/Endpoints/AdminSeasonResultsEndpoints.cs b/Backend/Endpoints/AdminSeasonResultsEndpoints.cs new file mode 100644 index 0000000..4f886b3 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonResultsEndpoints.cs @@ -0,0 +1,121 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task SetResult( + HttpContext context, + int seasonId, + SetAwardResultRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var category = await db.Categories + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId); + if (category is null) + { + return Results.BadRequest(new { message = "The selected category does not exist in this season." }); + } + + var candidate = await db.Candidates + .AsNoTracking() + .FirstOrDefaultAsync(item => + item.Id == request.CandidateId + && item.SeasonId == seasonId + && item.CategoryId == request.CategoryId); + if (candidate is null) + { + return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." }); + } + + var existingResult = await db.Results.FirstOrDefaultAsync(item => + item.SeasonId == seasonId + && item.CategoryId == request.CategoryId); + + if (existingResult is null) + { + existingResult = new AwardResult + { + SeasonId = seasonId, + CategoryId = request.CategoryId, + CandidateId = request.CandidateId, + CategoryName = category.Name, + }; + db.Results.Add(existingResult); + } + else + { + existingResult.CandidateId = request.CandidateId; + existingResult.CategoryName = category.Name; + } + + adminAuditService.AddEntry( + session.TwitchUserId, + "result.set", + "result", + $"{seasonId}:{request.CategoryId}", + $"Gewinner für {category.Name} wurde gesetzt.", + new + { + seasonId, + categoryId = request.CategoryId, + candidateId = request.CandidateId, + candidateName = candidate.DisplayName, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new + { + saved = true, + resultId = existingResult.Id, + seasonId, + categoryId = request.CategoryId, + candidateId = request.CandidateId, + }); + } + + private static async Task DeleteResult( + HttpContext context, + int resultId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var result = await db.Results + .Include(item => item.Category) + .Include(item => item.Candidate) + .FirstOrDefaultAsync(item => item.Id == resultId); + if (result is null) + { + return Results.NotFound(); + } + + db.Results.Remove(result); + adminAuditService.AddEntry( + session.TwitchUserId, + "result.delete", + "result", + result.Id.ToString(), + $"Gewinner für {result.Category.Name} wurde entfernt.", + new + { + result.SeasonId, + result.CategoryId, + result.CandidateId, + candidateName = result.Candidate.DisplayName, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, resultId }); + } +} diff --git a/Backend/Endpoints/AdminSeasonUpdateEndpoints.cs b/Backend/Endpoints/AdminSeasonUpdateEndpoints.cs new file mode 100644 index 0000000..8322910 --- /dev/null +++ b/Backend/Endpoints/AdminSeasonUpdateEndpoints.cs @@ -0,0 +1,115 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task UpdateSeason( + HttpContext context, + int seasonId, + UpdateSeasonRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId); + if (season is null) + { + return Results.NotFound(); + } + + var validationError = ValidateSeasonRequest(request); + if (validationError is not null) + { + return validationError; + } + + if (await db.Seasons.AnyAsync(item => item.Id != seasonId && item.Year == request.Year)) + { + return Results.BadRequest(new { message = $"A season for {request.Year} already exists." }); + } + + var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl); + + var wasCurrent = season.IsCurrent; + var previousPhase = season.CurrentPhase; + var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase); + var requestedPhaseKey = SeasonMappings.NormalizePhaseKey(request.CurrentPhase); + var shouldValidateReadiness = request.IsCurrent + || (string.Equals(requestedPhaseKey, "completed", StringComparison.Ordinal) + && !string.Equals(previousPhaseKey, "completed", StringComparison.Ordinal)); + if (shouldValidateReadiness) + { + var readinessIssues = await BuildSeasonReadinessIssuesAsync( + db, + seasonId, + request.CurrentPhase, + request.IsCurrent, + context.RequestAborted); + if (readinessIssues.Length > 0) + { + return CreateReadinessError(readinessIssues); + } + } + + season.Year = request.Year; + season.Name = request.Name.Trim(); + season.ShowStreamUrl = showStreamUrl; + season.CurrentPhase = request.CurrentPhase.Trim(); + season.IsCommunityOnly = request.IsCommunityOnly; + season.NominationStartsAt = request.NominationStartsAt; + season.NominationEndsAt = request.NominationEndsAt; + season.VotingStartsAt = request.VotingStartsAt; + season.VotingEndsAt = request.VotingEndsAt; + season.ReviewStartsAt = request.ReviewStartsAt; + season.ReviewEndsAt = request.ReviewEndsAt; + season.ShowDate = request.ShowDate; + season.ShowStartsAt = request.ShowStartsAt; + + await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent && !wasCurrent, seasonId, context.RequestAborted); + + season.IsCurrent = request.IsCurrent; + var actionType = "season.update"; + var summary = $"Season {season.Year} wurde aktualisiert."; + if (!string.Equals(previousPhase.Trim(), season.CurrentPhase, StringComparison.OrdinalIgnoreCase)) + { + actionType = "season.phase.update"; + summary = $"Phase fuer Season {season.Year} wurde auf {season.CurrentPhase} gesetzt."; + } + else if (wasCurrent != request.IsCurrent) + { + actionType = "season.public.update"; + summary = request.IsCurrent + ? $"Season {season.Year} wurde als Public-Kontext aktiviert." + : $"Season {season.Year} wurde aus dem Public-Kontext entfernt."; + } + + adminAuditService.AddEntry( + session.TwitchUserId, + actionType, + "season", + season.Id.ToString(), + summary, + new + { + request.Year, + request.Name, + showStreamUrl, + previousPhase, + request.CurrentPhase, + wasCurrent, + request.IsCurrent, + request.IsCommunityOnly, + request.ShowDate, + request.ShowStartsAt, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, seasonId = season.Id }); + } +} diff --git a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs new file mode 100644 index 0000000..c398194 --- /dev/null +++ b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs @@ -0,0 +1,353 @@ +using System.Text.Json; +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Domain; +using Backend.Security; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static class AdminSiteSettingsEndpoints +{ + private const string FallbackMaintenanceTitle = "Sternenpause"; + private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."; + + public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/site-settings", GetSiteSettings).WithName("GetAdminSiteSettings").WithOpenApi(); + group.MapPut("/site-settings", UpdateSiteSettings).WithName("UpdateAdminSiteSettings").WithOpenApi(); + group.MapGet("/operational-settings", GetOperationalSettings).WithName("GetAdminOperationalSettings").WithOpenApi(); + group.MapPut("/operational-settings", UpdateOperationalSettings).WithName("UpdateAdminOperationalSettings").WithOpenApi(); + return group; + } + + private static async Task GetSiteSettings(AwardsDbContext db) + { + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + return Results.Ok(new AdminSiteSettingsResponse( + settings.HostDisplayName, + settings.HostTagline, + settings.NewsletterUrl, + settings.PrivacyEmail, + settings.PrivacyPolicyContent, + settings.PrivacyPolicyUpdatedBy, + settings.PrivacyPolicyUpdatedAt, + settings.ImprintUrl, + settings.ContactUrl, + settings.SponsorsUrl, + SeasonMappings.ReadSocialLinks(settings), + SeasonMappings.ReadFaqItems(settings))); + } + + private static async Task UpdateSiteSettings( + HttpContext context, + UpdateSiteSettingsRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + if (!AdminRoles.CanManageContent(session.Role)) + { + return Results.Json(new { message = "Landingpage content requires a content admin, admin or owner role." }, statusCode: StatusCodes.Status403Forbidden); + } + + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + settings.HostDisplayName = request.HostDisplayName.Trim(); + settings.HostTagline = request.HostTagline.Trim(); + settings.NewsletterUrl = request.NewsletterUrl.Trim(); + settings.PrivacyEmail = request.PrivacyEmail.Trim(); + var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim(); + var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal); + settings.PrivacyPolicyContent = trimmedPrivacyContent; + if (privacyChanged) + { + settings.PrivacyPolicyUpdatedBy = session.DisplayName.Trim(); + settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow; + } + + settings.ImprintUrl = request.ImprintUrl.Trim(); + settings.ContactUrl = request.ContactUrl.Trim(); + settings.SponsorsUrl = request.SponsorsUrl.Trim(); + settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []); + settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []); + + adminAuditService.AddEntry( + session.TwitchUserId, + "site-settings.update", + "site-settings", + settings.Id.ToString(), + "Public Site Settings wurden aktualisiert.", + new + { + settings.HostDisplayName, + privacyChanged, + socialLinkCount = request.SocialLinks?.Length ?? 0, + faqCount = request.Faq?.Length ?? 0, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true }); + } + + private static async Task GetOperationalSettings(AwardsDbContext db, IConfiguration configuration) + { + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings); + return Results.Ok(new AdminOperationalSettingsResponse( + usesDatabaseDemo, + usesDatabaseDemo ? settings.DemoLoginEnabled : IsDemoLoginEnabled(configuration), + usesDatabaseDemo ? settings.DemoLoginEmail : ReadDemoLoginIdentifier(configuration), + usesDatabaseDemo ? HasDatabaseDemoCredentials(settings) : !string.IsNullOrWhiteSpace(ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD")), + usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"), + usesDatabaseDemo ? settings.DemoLoginDisplayName : ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"), + settings.MaintenanceModeEnabled, + string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, + string.IsNullOrWhiteSpace(settings.MaintenanceMessage) + ? FallbackMaintenanceMessage + : settings.MaintenanceMessage)); + } + + private static async Task UpdateOperationalSettings( + HttpContext context, + UpdateOperationalSettingsRequest request, + AwardsDbContext db, + IConfiguration configuration, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + if (!AdminRoles.CanManageOperationalSettings(session.Role)) + { + return Results.Json(new { message = "Demo-Zugang und Wartungsmodus können nur Owner ändern." }, statusCode: StatusCodes.Status403Forbidden); + } + + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + var before = CreateOperationalSettingsSnapshot(settings); + var loginIdentifier = request.DemoLoginEmail.Trim(); + var twitchUserId = request.DemoLoginTwitchUserId.Trim(); + var displayName = request.DemoLoginDisplayName.Trim(); + var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty; + var fallbackPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD"); + + if (request.DemoLoginEnabled) + { + if (string.IsNullOrWhiteSpace(loginIdentifier) + || string.IsNullOrWhiteSpace(twitchUserId) + || string.IsNullOrWhiteSpace(displayName)) + { + return Results.BadRequest(new { message = "Demo-Login braucht Login, Twitch-ID und Anzeigenamen." }); + } + + if (string.IsNullOrWhiteSpace(newPassword) + && !HasDatabaseDemoCredentials(settings) + && string.IsNullOrWhiteSpace(fallbackPassword)) + { + return Results.BadRequest(new { message = "Bitte setze beim ersten Aktivieren ein Demo-Passwort." }); + } + } + + if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 12) + { + return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 12 Zeichen lang sein." }); + } + + settings.DemoLoginManagedByDatabase = true; + settings.DemoLoginEnabled = request.DemoLoginEnabled; + settings.DemoLoginEmail = loginIdentifier; + settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId; + settings.DemoLoginDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Jayuhime Admin" : displayName; + + var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword) + ? newPassword + : request.DemoLoginEnabled && !HasDatabaseDemoCredentials(settings) + ? fallbackPassword + : string.Empty; + + if (!string.IsNullOrWhiteSpace(passwordToPersist)) + { + var credentials = DemoCredentialHasher.HashPassword(passwordToPersist); + settings.DemoLoginPasswordHash = credentials.Hash; + settings.DemoLoginPasswordSalt = credentials.Salt; + } + + settings.MaintenanceModeEnabled = request.MaintenanceModeEnabled; + settings.MaintenanceTitle = NormalizeOperationalText(request.MaintenanceTitle, FallbackMaintenanceTitle, 120); + settings.MaintenanceMessage = NormalizeOperationalText( + request.MaintenanceMessage, + FallbackMaintenanceMessage, + 600); + + var changes = BuildOperationalSettingChanges( + before, + CreateOperationalSettingsSnapshot(settings), + !string.IsNullOrWhiteSpace(passwordToPersist)); + + adminAuditService.AddEntry( + session.TwitchUserId, + "operational-settings.update", + "site-settings", + settings.Id.ToString(), + "Demo-Zugang und Wartungsmodus wurden aktualisiert.", + new + { + settings.DemoLoginEnabled, + passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist), + settings.MaintenanceModeEnabled, + changes, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + + return Results.Ok(new + { + saved = true, + demoLoginPasswordSet = HasDatabaseDemoCredentials(settings), + }); + } + + private static string NormalizeOperationalText(string value, string fallback, int maxLength) + { + var trimmed = value.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return fallback; + } + + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private static bool IsDemoLoginEnabled(IConfiguration configuration) + { + var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"] + ?? configuration["DemoAdmin:Enabled"]; + + return bool.TryParse(rawValue, out var enabled) && enabled; + } + + private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) => + configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty; + + private static string ReadDemoLoginIdentifier(IConfiguration configuration) + { + var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN"); + return string.IsNullOrWhiteSpace(configuredLogin) + ? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL") + : configuredLogin; + } + + private static bool HasDatabaseDemoCredentials(SiteSettings settings) => + !string.IsNullOrWhiteSpace(settings.DemoLoginEmail) + && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash) + && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt); + + private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings) => + new( + settings.DemoLoginManagedByDatabase, + settings.DemoLoginEnabled, + settings.DemoLoginEmail, + HasDatabaseDemoCredentials(settings), + settings.DemoLoginTwitchUserId, + settings.DemoLoginDisplayName, + settings.MaintenanceModeEnabled, + string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, + string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage); + + private static object[] BuildOperationalSettingChanges( + OperationalSettingsSnapshot before, + OperationalSettingsSnapshot after, + bool passwordChanged) + { + var changes = new List(); + + AddOperationalChange(changes, "demoLoginManagedByDatabase", "Demo Quelle", before.DemoLoginManagedByDatabase, after.DemoLoginManagedByDatabase); + AddOperationalChange(changes, "demoLoginEnabled", "Demo Login", before.DemoLoginEnabled, after.DemoLoginEnabled); + AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail); + AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId); + AddOperationalChange(changes, "demoLoginDisplayName", "Demo Anzeigename", before.DemoLoginDisplayName, after.DemoLoginDisplayName); + + if (passwordChanged) + { + changes.Add(new + { + field = "demoLoginPassword", + label = "Demo Passwort", + @from = before.DemoLoginPasswordSet ? "gesetzt" : "nicht gesetzt", + to = "neu gesetzt", + sensitive = true, + }); + } + + AddOperationalChange(changes, "maintenanceModeEnabled", "Wartungsmodus", before.MaintenanceModeEnabled, after.MaintenanceModeEnabled); + AddOperationalChange(changes, "maintenanceTitle", "Wartungstitel", before.MaintenanceTitle, after.MaintenanceTitle); + AddOperationalChange(changes, "maintenanceMessage", "Wartungstext", before.MaintenanceMessage, after.MaintenanceMessage); + + return changes.ToArray(); + } + + private static void AddOperationalChange( + ICollection changes, + string field, + string label, + T before, + T after) + { + if (EqualityComparer.Default.Equals(before, after)) + { + return; + } + + changes.Add(new + { + field, + label, + @from = FormatOperationalAuditValue(before), + to = FormatOperationalAuditValue(after), + sensitive = false, + }); + } + + private static string FormatOperationalAuditValue(T value) + { + if (value is bool booleanValue) + { + return booleanValue ? "aktiv" : "aus"; + } + + var text = Convert.ToString(value)?.Trim() ?? string.Empty; + return string.IsNullOrWhiteSpace(text) ? "leer" : text; + } + + private sealed record OperationalSettingsSnapshot( + bool DemoLoginManagedByDatabase, + bool DemoLoginEnabled, + string DemoLoginEmail, + bool DemoLoginPasswordSet, + string DemoLoginTwitchUserId, + string DemoLoginDisplayName, + bool MaintenanceModeEnabled, + string MaintenanceTitle, + string MaintenanceMessage); +} diff --git a/Backend/Endpoints/AuthDataDeletionEndpoints.cs b/Backend/Endpoints/AuthDataDeletionEndpoints.cs new file mode 100644 index 0000000..47943ee --- /dev/null +++ b/Backend/Endpoints/AuthDataDeletionEndpoints.cs @@ -0,0 +1,70 @@ +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AuthEndpoints +{ + private static async Task DeleteMyParticipationData( + HttpContext context, + AwardsDbContext db, + IUserSessionService userSessionService) + { + var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); + if (session is null) + { + return Results.Unauthorized(); + } + + var twitchUserId = session.TwitchUserId; + await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted); + + var ballotIds = await db.VoteBallots + .Where(item => item.SubmittedByTwitchId == twitchUserId) + .Select(item => item.Id) + .ToArrayAsync(context.RequestAborted); + + var deletedVoteEntries = ballotIds.Length == 0 + ? 0 + : await db.VoteEntries + .Where(item => ballotIds.Contains(item.BallotId)) + .ExecuteDeleteAsync(context.RequestAborted); + + var deletedBallots = await db.VoteBallots + .Where(item => item.SubmittedByTwitchId == twitchUserId) + .ExecuteDeleteAsync(context.RequestAborted); + + var deletedNominations = await db.Nominations + .Where(item => item.SubmittedByTwitchId == twitchUserId) + .ExecuteDeleteAsync(context.RequestAborted); + + var deletedClips = await db.ClipSubmissions + .Where(item => item.SubmittedByTwitchId == twitchUserId) + .ExecuteDeleteAsync(context.RequestAborted); + + var deletedRiskFlags = await db.RiskFlags + .Where(item => item.TwitchUserId == twitchUserId) + .ExecuteDeleteAsync(context.RequestAborted); + + var disabledSessions = await db.UserSessions + .Where(item => item.TwitchUserId == twitchUserId) + .ExecuteUpdateAsync( + setters => setters.SetProperty(item => item.IsActive, false), + context.RequestAborted); + + await transaction.CommitAsync(context.RequestAborted); + + return Results.Ok(new + { + deleted = true, + twitchUserId, + deletedVoteEntries, + deletedBallots, + deletedNominations, + deletedClips, + deletedRiskFlags, + disabledSessions, + }); + } +} diff --git a/Backend/Endpoints/AuthDemoLoginEndpoints.cs b/Backend/Endpoints/AuthDemoLoginEndpoints.cs new file mode 100644 index 0000000..05e0b6d --- /dev/null +++ b/Backend/Endpoints/AuthDemoLoginEndpoints.cs @@ -0,0 +1,182 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Domain; +using Backend.Security; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AuthEndpoints +{ + private static async Task DemoLogin( + HttpContext context, + AwardsDbContext db, + IConfiguration configuration, + DemoLoginRequest request, + IUserSessionService userSessionService, + IRiskFlagService riskFlagService, + IRiskRuleService riskRuleService) + { + var login = request.Login?.Trim() ?? request.Email?.Trim() ?? string.Empty; + var password = request.Password ?? string.Empty; + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); + var databaseDemoConfigured = settings is not null + && (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings)); + + string twitchUserId; + string displayName; + bool credentialsMatch; + + if (databaseDemoConfigured && settings is not null) + { + if (!settings.DemoLoginEnabled) + { + return Results.NotFound(); + } + + if (!HasDatabaseDemoCredentials(settings) + || string.IsNullOrWhiteSpace(settings.DemoLoginTwitchUserId) + || string.IsNullOrWhiteSpace(settings.DemoLoginDisplayName)) + { + return Results.Json( + new { message = "Demo login is not fully configured." }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + credentialsMatch = LoginMatchesIdentifier( + login, + settings.DemoLoginEmail, + settings.DemoLoginTwitchUserId, + settings.DemoLoginDisplayName) + && DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt); + twitchUserId = settings.DemoLoginTwitchUserId.Trim(); + displayName = settings.DemoLoginDisplayName.Trim(); + } + else + { + if (!IsDemoLoginEnabled(configuration)) + { + return Results.NotFound(); + } + + var configuredLogin = ReadDemoLoginIdentifier(configuration); + var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL"); + var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD"); + twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"); + displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"); + + if (string.IsNullOrWhiteSpace(configuredLogin) + || string.IsNullOrWhiteSpace(configuredPassword) + || string.IsNullOrWhiteSpace(twitchUserId) + || string.IsNullOrWhiteSpace(displayName)) + { + return Results.Json( + new { message = "Demo login is not fully configured." }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + credentialsMatch = LoginMatchesIdentifier( + login, + configuredLogin, + configuredEmail, + twitchUserId, + displayName) + && DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword); + twitchUserId = twitchUserId.Trim(); + displayName = displayName.Trim(); + } + + if (!credentialsMatch) + { + return Results.Unauthorized(); + } + + var requestMetadata = RequestMetadataReader.Read(context); + var session = await userSessionService.CreateSessionAsync( + twitchUserId, + displayName, + AdminRoles.Owner, + requestMetadata, + context.RequestAborted); + + var rapidDemoLoginRule = await riskRuleService.GetRuleAsync("rapid_demo_login_ip", context.RequestAborted); + var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync( + requestMetadata.ClientIp, + DateTimeOffset.UtcNow.AddMinutes(-rapidDemoLoginRule.WindowMinutes), + context.RequestAborted); + + if (rapidDemoLoginRule.Enabled && recentSessionsFromIp >= rapidDemoLoginRule.Threshold) + { + await riskFlagService.AddIfMissingAsync( + null, + session.TwitchUserId, + "login", + "rapid_demo_login_ip", + rapidDemoLoginRule.Severity, + "Mehrere Demo-Admin-Sessions wurden in kurzer Zeit von derselben IP erzeugt.", + requestMetadata, + new + { + recentSessionsFromIp, + threshold = rapidDemoLoginRule.Threshold, + windowMinutes = rapidDemoLoginRule.WindowMinutes, + entityLinks = new[] + { + new + { + label = "Audit-Log öffnen", + entityType = "session", + entityId = session.TwitchUserId, + to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}", + }, + }, + }, + context.RequestAborted); + await db.SaveChangesAsync(context.RequestAborted); + } + + return Results.Ok(ToAuthSessionDto(session)); + } + + private static bool IsDemoLoginEnabled(IConfiguration configuration) + { + var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"] + ?? configuration["DemoAdmin:Enabled"]; + + return bool.TryParse(rawValue, out var enabled) && enabled; + } + + private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) => + configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty; + + private static string ReadDemoLoginIdentifier(IConfiguration configuration) + { + var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN"); + return string.IsNullOrWhiteSpace(configuredLogin) + ? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL") + : configuredLogin; + } + + private static bool HasDatabaseDemoCredentials(SiteSettings settings) => + !string.IsNullOrWhiteSpace(settings.DemoLoginEmail) + && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash) + && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt); + + private static bool LoginMatchesIdentifier(string login, params string?[] validIdentifiers) + { + var normalizedLogin = NormalizeLoginIdentifier(login); + if (string.IsNullOrWhiteSpace(normalizedLogin)) + { + return false; + } + + return validIdentifiers + .Select(NormalizeLoginIdentifier) + .Any(identifier => string.Equals(normalizedLogin, identifier, StringComparison.OrdinalIgnoreCase)); + } + + private static string NormalizeLoginIdentifier(string? value) => + (value ?? string.Empty).Trim().TrimStart('@'); +} diff --git a/Backend/Endpoints/AuthDevelopmentLoginEndpoints.cs b/Backend/Endpoints/AuthDevelopmentLoginEndpoints.cs new file mode 100644 index 0000000..1f90cc8 --- /dev/null +++ b/Backend/Endpoints/AuthDevelopmentLoginEndpoints.cs @@ -0,0 +1,101 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Security; +using Backend.Services; + +namespace Backend.Endpoints; + +public static partial class AuthEndpoints +{ + private const int MaxTwitchUserIdLength = 64; + private const int MaxDisplayNameLength = 80; + + private static async Task DevLogin( + HttpContext context, + IHostEnvironment environment, + LoginRequest request, + AwardsDbContext db, + IUserSessionService userSessionService, + IRiskFlagService riskFlagService, + IRiskRuleService riskRuleService) + { + if (!environment.IsDevelopment()) + { + return Results.NotFound(); + } + + var normalizedTwitchUserId = request.TwitchUserId?.Trim() ?? string.Empty; + var normalizedDisplayName = request.DisplayName?.Trim() ?? string.Empty; + var normalizedRole = AdminRoles.Normalize(request.Role); + + if (string.IsNullOrWhiteSpace(normalizedTwitchUserId) || normalizedTwitchUserId.Length > MaxTwitchUserIdLength) + { + return Results.BadRequest(new { message = $"Twitch user id is required and must stay below {MaxTwitchUserIdLength} characters." }); + } + + if (!normalizedTwitchUserId.All(value => char.IsLetterOrDigit(value) || value is '_' or '-')) + { + return Results.BadRequest(new { message = "Twitch user id contains unsupported characters." }); + } + + if (string.IsNullOrWhiteSpace(normalizedDisplayName) || normalizedDisplayName.Length > MaxDisplayNameLength) + { + return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxDisplayNameLength} characters." }); + } + + if (!AdminRoles.IsKnownRole(request.Role)) + { + return Results.BadRequest(new { message = "Role must be viewer, content_admin, admin or owner." }); + } + + var requestMetadata = RequestMetadataReader.Read(context); + var session = await userSessionService.CreateDevSessionAsync( + request with + { + TwitchUserId = normalizedTwitchUserId, + DisplayName = normalizedDisplayName, + Role = normalizedRole, + }, + requestMetadata, + context.RequestAborted); + + var rapidLoginRule = await riskRuleService.GetRuleAsync("rapid_login_ip", context.RequestAborted); + var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync( + requestMetadata.ClientIp, + DateTimeOffset.UtcNow.AddMinutes(-rapidLoginRule.WindowMinutes), + context.RequestAborted); + + if (rapidLoginRule.Enabled && recentSessionsFromIp >= rapidLoginRule.Threshold) + { + await riskFlagService.AddIfMissingAsync( + null, + session.TwitchUserId, + "login", + "rapid_login_ip", + rapidLoginRule.Severity, + "Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.", + requestMetadata, + new + { + recentSessionsFromIp, + threshold = rapidLoginRule.Threshold, + windowMinutes = rapidLoginRule.WindowMinutes, + entityLinks = new[] + { + new + { + label = "Audit-Log öffnen", + entityType = "session", + entityId = session.TwitchUserId, + to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}", + }, + }, + }, + context.RequestAborted); + await db.SaveChangesAsync(context.RequestAborted); + } + + return Results.Ok(ToAuthSessionDto(session)); + } +} diff --git a/Backend/Endpoints/AuthEndpoints.cs b/Backend/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..2b3de07 --- /dev/null +++ b/Backend/Endpoints/AuthEndpoints.cs @@ -0,0 +1,37 @@ +using Backend.Common; + +namespace Backend.Endpoints; + +public static partial class AuthEndpoints +{ + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/auth"); + + group.MapPost("/dev-login", DevLogin) + .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) + .WithName("DevLogin") + .WithOpenApi(); + + group.MapPost("/demo-login", DemoLogin) + .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) + .WithName("DemoLogin") + .WithOpenApi(); + + group.MapGet("/session", GetSession) + .WithName("GetSession") + .WithOpenApi(); + + group.MapPost("/logout", Logout) + .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) + .WithName("Logout") + .WithOpenApi(); + + group.MapDelete("/me/data", DeleteMyParticipationData) + .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) + .WithName("DeleteMyParticipationData") + .WithOpenApi(); + + return app; + } +} diff --git a/Backend/Endpoints/AuthSessionEndpoints.cs b/Backend/Endpoints/AuthSessionEndpoints.cs new file mode 100644 index 0000000..82dfd5a --- /dev/null +++ b/Backend/Endpoints/AuthSessionEndpoints.cs @@ -0,0 +1,38 @@ +using Backend.Contracts; +using Backend.Domain; +using Backend.Services; + +namespace Backend.Endpoints; + +public static partial class AuthEndpoints +{ + private static async Task GetSession(HttpContext context, IUserSessionService userSessionService) + { + var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); + if (session is null) + { + return Results.Unauthorized(); + } + + return Results.Ok(ToAuthSessionDto(session)); + } + + private static async Task Logout(HttpContext context, IUserSessionService userSessionService) + { + var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); + if (session is null) + { + return Results.Ok(new { loggedOut = true }); + } + + await userSessionService.LogoutAsync(session, context.RequestAborted); + return Results.Ok(new { loggedOut = true }); + } + + private static AuthSessionDto ToAuthSessionDto(UserSession session) => + new( + session.SessionToken, + session.TwitchUserId, + session.DisplayName, + session.Role); +} diff --git a/Backend/Endpoints/PublicClipEndpoints.cs b/Backend/Endpoints/PublicClipEndpoints.cs new file mode 100644 index 0000000..34a9e86 --- /dev/null +++ b/Backend/Endpoints/PublicClipEndpoints.cs @@ -0,0 +1,158 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task CreateClip( + HttpContext context, + CreateClipRequest request, + AwardsDbContext db, + IUserSessionService userSessionService, + IRiskFlagService riskFlagService, + IRiskRuleService riskRuleService) + { + if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl)) + { + return Results.BadRequest(new { message = "A valid http(s) clip link is required." }); + } + + var platform = ResolveClipPlatform(clipUrl); + if (platform is null) + { + return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." }); + } + + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year); + var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination"); + if (clipSeasonResolution.Result is not null) + { + return clipSeasonResolution.Result; + } + + season = clipSeasonResolution.Season!; + + var selectedCandidate = request.CandidateId is int candidateId + ? await db.Candidates + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id) + : null; + if (request.CandidateId is not null && selectedCandidate is null) + { + return Results.BadRequest(new { message = "The selected candidate does not exist for this season." }); + } + + if (request.CategoryId is int requestedCategoryId + && selectedCandidate is not null + && selectedCandidate.CategoryId != requestedCategoryId) + { + return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." }); + } + + var resolvedCategoryId = request.CategoryId ?? selectedCandidate?.CategoryId; + var normalizedTitle = request.Title?.Trim() ?? string.Empty; + var submittedCreator = request.Creator?.Trim(); + var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator) + ? selectedCandidate?.DisplayName ?? string.Empty + : submittedCreator; + if (normalizedTitle.Length > 160) + { + return Results.BadRequest(new { message = "Clip titles must stay below 160 characters." }); + } + + if (normalizedCreator.Length > 160) + { + return Results.BadRequest(new { message = "Creator names must stay below 160 characters." }); + } + + if (resolvedCategoryId is int categoryId) + { + var categoryExists = selectedCandidate?.CategoryId == categoryId + || await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id); + if (!categoryExists) + { + return Results.BadRequest(new { message = "The selected category does not exist for this season." }); + } + } + + var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService); + if (submitterIdResult.Result is not null) + { + return submitterIdResult.Result; + } + + var submitterId = submitterIdResult.SubmitterId!; + var requestMetadata = RequestMetadataReader.Read(context); + var duplicateClipRule = await riskRuleService.GetRuleAsync("duplicate_clip_submission", context.RequestAborted); + var rapidClipBurstRule = await riskRuleService.GetRuleAsync("rapid_clip_burst", context.RequestAborted); + var recentClipSubmissions = await db.ClipSubmissions.CountAsync(item => + item.SubmittedByTwitchId == submitterId + && item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidClipBurstRule.WindowMinutes)); + var alreadySubmittedClip = await db.ClipSubmissions.AnyAsync(item => + item.SeasonId == season.Id + && item.SubmittedByTwitchId == submitterId + && item.ClipUrl == clipUrl); + + var clip = new ClipSubmission + { + SeasonId = season.Id, + CategoryId = resolvedCategoryId, + CandidateId = selectedCandidate?.Id, + SubmittedByTwitchId = submitterId, + ClipUrl = clipUrl, + Title = normalizedTitle, + Creator = normalizedCreator, + Platform = platform, + Status = "pending", + CreatedFromIp = requestMetadata.ClientIp, + CreatedAt = DateTimeOffset.UtcNow, + }; + + db.ClipSubmissions.Add(clip); + await db.SaveChangesAsync(context.RequestAborted); + + var clipLink = new + { + label = "Clip öffnen", + entityType = "clip", + entityId = clip.Id.ToString(), + to = $"/admin/clips?query={Uri.EscapeDataString(clip.Id.ToString())}", + }; + + if (alreadySubmittedClip && duplicateClipRule.Enabled) + { + await riskFlagService.AddIfMissingAsync( + season.Id, + submitterId, + "clip", + "duplicate_clip_submission", + duplicateClipRule.Severity, + "Ein User hat denselben Clip erneut eingereicht.", + requestMetadata, + new { clipId = clip.Id, clipUrl, CategoryId = resolvedCategoryId, CandidateId = selectedCandidate?.Id, entityLinks = new[] { clipLink } }, + context.RequestAborted); + } + + if (rapidClipBurstRule.Enabled && recentClipSubmissions >= rapidClipBurstRule.Threshold) + { + await riskFlagService.AddIfMissingAsync( + season.Id, + submitterId, + "clip", + "rapid_clip_burst", + rapidClipBurstRule.Severity, + "Ungewoehnlich viele Clip-Einreichungen in kurzer Zeit erkannt.", + requestMetadata, + new { clipId = clip.Id, recentClipSubmissions, threshold = rapidClipBurstRule.Threshold, windowMinutes = rapidClipBurstRule.WindowMinutes, entityLinks = new[] { clipLink } }, + context.RequestAborted); + } + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, clipId = clip.Id }); + } +} diff --git a/Backend/Endpoints/PublicEndpointSupport.cs b/Backend/Endpoints/PublicEndpointSupport.cs new file mode 100644 index 0000000..37ea201 --- /dev/null +++ b/Backend/Endpoints/PublicEndpointSupport.cs @@ -0,0 +1,208 @@ +using Backend.Common; +using Backend.Domain; +using Backend.Services; +using Microsoft.AspNetCore.Http.Extensions; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private readonly record struct SubmitterIdResolution(string? SubmitterId, IResult? Result); + private readonly record struct PublicWriteSeasonResolution(Season? Season, IResult? Result); + private sealed record PublicCandidateClip( + int? CategoryId, + int? CandidateId, + string Creator, + string ClipUrl, + string Title, + string Platform, + DateTimeOffset CreatedAt, + DateTimeOffset? ReviewedAt); + + private static async Task ResolveSubmitterIdAsync( + HttpContext context, + string? fallbackTwitchUserId, + IUserSessionService userSessionService) + { + var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); + if (session is null) + { + return new SubmitterIdResolution( + null, + Results.Json( + new { message = "A logged in user is required to submit this action." }, + statusCode: StatusCodes.Status401Unauthorized)); + } + + var submittedTwitchUserId = NormalizeSubmittedTwitchUserId(fallbackTwitchUserId); + if (submittedTwitchUserId is not null + && !string.Equals(submittedTwitchUserId, session.TwitchUserId, StringComparison.OrdinalIgnoreCase)) + { + return new SubmitterIdResolution( + null, + Results.BadRequest(new { message = "Submitted user identity does not match the active session." })); + } + + return new SubmitterIdResolution(session.TwitchUserId, null); + } + + private static string? NormalizeSubmittedTwitchUserId(string? twitchUserId) + { + var normalized = twitchUserId?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + private static PublicWriteSeasonResolution EnsurePublicWriteSeason( + Season? season, + params string[] allowedPhaseKeys) + { + if (season is null) + { + return new PublicWriteSeasonResolution(null, Results.BadRequest(new { message = "The selected season does not exist." })); + } + + if (!season.IsCurrent) + { + return new PublicWriteSeasonResolution( + null, + Results.BadRequest(new { message = "Submissions are only allowed for the active season." })); + } + + var currentPhaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase); + if (!allowedPhaseKeys.Contains(currentPhaseKey, StringComparer.OrdinalIgnoreCase)) + { + var phaseLabel = DescribePublicPhase(currentPhaseKey); + return new PublicWriteSeasonResolution( + null, + Results.BadRequest(new + { + message = $"This action is not available during the current season phase ({phaseLabel}).", + })); + } + + return new PublicWriteSeasonResolution(season, null); + } + + private static bool TryNormalizeExternalUrl(string? rawUrl, out string normalizedUrl) + { + normalizedUrl = string.Empty; + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return false; + } + + if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri)) + { + return false; + } + + if (uri.Scheme is not ("http" or "https")) + { + return false; + } + + normalizedUrl = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped); + return true; + } + + private static string? ResolveClipPlatform(string clipUrl) + { + if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri)) + { + return null; + } + + var host = uri.Host.ToLowerInvariant(); + if (host is "twitch.tv" or "www.twitch.tv" or "clips.twitch.tv") + { + return "Twitch"; + } + + if (host is "youtube.com" or "www.youtube.com" or "m.youtube.com" or "youtu.be") + { + return "YouTube"; + } + + return null; + } + + private static bool ShouldExposePublicCategory(string phaseKey, int candidateCount) => + string.Equals(phaseKey, "nomination", StringComparison.OrdinalIgnoreCase) || candidateCount > 0; + + private static Dictionary BuildCandidateClipLookup(IEnumerable clips) => + clips + .Where(clip => clip.CandidateId is not null) + .GroupBy(clip => clip.CandidateId!.Value) + .ToDictionary( + grouping => grouping.Key, + grouping => grouping + .OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt) + .First()); + + private static Dictionary BuildCreatorClipLookup(IEnumerable clips) => + clips + .Where(clip => clip.CategoryId is not null && !string.IsNullOrWhiteSpace(clip.Creator)) + .GroupBy(clip => BuildCandidateClipLookupKey(clip.CategoryId!.Value, clip.Creator)) + .Where(grouping => !string.IsNullOrWhiteSpace(grouping.Key)) + .ToDictionary( + grouping => grouping.Key, + grouping => grouping + .OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt) + .First()); + + private static PublicCandidateClip? ResolveCandidateClip( + Candidate candidate, + IReadOnlyDictionary clipsByCandidateId, + IReadOnlyDictionary clipsByCreatorKey) + { + if (clipsByCandidateId.TryGetValue(candidate.Id, out var directClip)) + { + return directClip; + } + + foreach (var key in BuildCandidateClipLookupKeys(candidate)) + { + if (clipsByCreatorKey.TryGetValue(key, out var fallbackClip)) + { + return fallbackClip; + } + } + + return null; + } + + private static IEnumerable BuildCandidateClipLookupKeys(Candidate candidate) + { + yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.DisplayName); + yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug); + yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug.TrimStart('@')); + } + + private static string BuildCandidateClipLookupKey(int categoryId, string value) + { + var key = NormalizeCandidateClipKey(value); + return string.IsNullOrWhiteSpace(key) ? string.Empty : $"{categoryId}:{key}"; + } + + private static string NormalizeCandidateClipKey(string value) + { + var normalizedCharacters = value + .Trim() + .TrimStart('@') + .ToLowerInvariant() + .Where(char.IsLetterOrDigit) + .ToArray(); + + return new string(normalizedCharacters); + } + + private static string DescribePublicPhase(string phaseKey) => + phaseKey switch + { + "nomination" => "nomination", + "voting" => "voting", + "review" => "review", + "show" => "show", + _ => "current", + }; +} diff --git a/Backend/Endpoints/PublicEndpoints.cs b/Backend/Endpoints/PublicEndpoints.cs new file mode 100644 index 0000000..a0284ad --- /dev/null +++ b/Backend/Endpoints/PublicEndpoints.cs @@ -0,0 +1,46 @@ +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + public static IEndpointRouteBuilder MapPublicEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/public"); + + group.MapGet("/overview", GetOverview) + .WithName("GetOverview") + .WithOpenApi(); + + group.MapGet("/site-status", GetSiteStatus) + .WithName("GetSiteStatus") + .WithOpenApi(); + + group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories) + .WithName("GetSeasonCategories") + .WithOpenApi(); + + group.MapGet("/seasons/{year:int}/winners", GetWinnerArchive) + .WithName("GetWinnerArchive") + .WithOpenApi(); + + group.MapGet("/seasons/{year:int}/me", GetUserParticipation) + .WithName("GetUserParticipation") + .WithOpenApi(); + + group.MapPost("/nominations", CreateNomination) + .RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy) + .WithName("CreateNomination") + .WithOpenApi(); + + group.MapPost("/votes", CreateVote) + .RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy) + .WithName("CreateVote") + .WithOpenApi(); + + group.MapPost("/clips", CreateClip) + .RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy) + .WithName("CreateClip") + .WithOpenApi(); + + return app; + } +} diff --git a/Backend/Endpoints/PublicNominationEndpoints.cs b/Backend/Endpoints/PublicNominationEndpoints.cs new file mode 100644 index 0000000..f5d4aff --- /dev/null +++ b/Backend/Endpoints/PublicNominationEndpoints.cs @@ -0,0 +1,181 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task CreateNomination( + HttpContext context, + CreateNominationRequest request, + AwardsDbContext db, + IUserSessionService userSessionService, + IRiskFlagService riskFlagService, + IRiskRuleService riskRuleService) + { + var submittedNominations = NormalizeSubmittedNominations(request); + + if (submittedNominations.Length is 0 or > 3) + { + return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." }); + } + + if (submittedNominations.Any(item => item.Name.Length > 120)) + { + return Results.BadRequest(new { message = "Nominee names must stay below 120 characters." }); + } + + if (submittedNominations.Any(item => item.StreamUrl.Length > 300)) + { + return Results.BadRequest(new { message = "Stream links must stay below 300 characters." }); + } + + if (request.Nominations is { Length: > 0 } && submittedNominations.Any(item => string.IsNullOrWhiteSpace(item.StreamUrl))) + { + return Results.BadRequest(new { message = "A stream link is required for every nomination." }); + } + + var distinctNomineeNames = submittedNominations + .Select(item => item.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (distinctNomineeNames.Length != submittedNominations.Length) + { + return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." }); + } + + var invalidStreamUrl = submittedNominations + .Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl)) + .Select(item => item.StreamUrl) + .FirstOrDefault(item => !TryNormalizeExternalUrl(item, out _)); + + if (invalidStreamUrl is not null) + { + return Results.BadRequest(new { message = "A valid http(s) stream link is required." }); + } + + var category = await db.Categories + .Include(item => item.Season) + .FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year); + + if (category is null) + { + return Results.BadRequest(new { message = "The selected category does not exist for this season." }); + } + + var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination"); + if (nominationSeasonResolution.Result is not null) + { + return nominationSeasonResolution.Result; + } + + var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService); + if (submitterIdResult.Result is not null) + { + return submitterIdResult.Result; + } + + var submitterId = submitterIdResult.SubmitterId!; + var requestMetadata = RequestMetadataReader.Read(context); + var existingNominationCount = await db.Nominations.CountAsync(item => + item.SeasonId == category.SeasonId + && item.CategoryId == category.Id + && item.SubmittedByTwitchId == submitterId + && item.Status == "pending"); + + var records = submittedNominations.Select(nomination => new Nomination + { + SeasonId = category.SeasonId, + CategoryId = category.Id, + SubmittedByTwitchId = submitterId, + CandidateText = nomination.Name, + StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl, + Status = "pending", + ReviewNote = string.IsNullOrWhiteSpace(nomination.StreamUrl) + ? null + : $"Stream-Link: {nomination.StreamUrl}", + CreatedAt = DateTimeOffset.UtcNow, + }).ToArray(); + + await db.Nominations.AddRangeAsync(records); + + var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted); + var rapidNominationBurstRule = await riskRuleService.GetRuleAsync("rapid_nomination_burst", context.RequestAborted); + var recentNominationVolume = await db.Nominations.CountAsync(item => + item.SubmittedByTwitchId == submitterId + && item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidNominationBurstRule.WindowMinutes)); + + await db.SaveChangesAsync(context.RequestAborted); + var reviewLink = new + { + label = "Review-Fälle öffnen", + entityType = "nomination", + entityId = string.Join(",", records.Select(item => item.Id)), + to = $"/admin/reviews?query={Uri.EscapeDataString(submitterId)}", + }; + + if (existingNominationCount > 0 && resubmittedNominationRule.Enabled) + { + await riskFlagService.AddIfMissingAsync( + category.SeasonId, + submitterId, + "nomination", + "resubmitted_nomination", + resubmittedNominationRule.Severity, + "Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.", + requestMetadata, + new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } }, + context.RequestAborted); + } + + if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold) + { + await riskFlagService.AddIfMissingAsync( + category.SeasonId, + submitterId, + "nomination", + "rapid_nomination_burst", + rapidNominationBurstRule.Severity, + "Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.", + requestMetadata, + new { recentNominationVolume, threshold = rapidNominationBurstRule.Threshold, windowMinutes = rapidNominationBurstRule.WindowMinutes, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } }, + context.RequestAborted); + } + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 }); + } + + private readonly record struct SubmittedNomination(string Name, string StreamUrl); + + private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request) + { + if (request.Nominations is { Length: > 0 }) + { + return request.Nominations + .Select(item => + { + var name = item.Name?.Trim() ?? string.Empty; + var streamUrl = item.StreamUrl?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl)) + { + streamUrl = normalizedUrl; + } + + return new SubmittedNomination(name, streamUrl); + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)) + .ToArray(); + } + + return (request.Nominees ?? []) + .Select(item => new SubmittedNomination(item.Trim(), string.Empty)) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)) + .ToArray(); + } +} diff --git a/Backend/Endpoints/PublicOverviewEndpoints.cs b/Backend/Endpoints/PublicOverviewEndpoints.cs new file mode 100644 index 0000000..19f9b3b --- /dev/null +++ b/Backend/Endpoints/PublicOverviewEndpoints.cs @@ -0,0 +1,99 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task GetOverview(AwardsDbContext db) + { + var siteSettings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + var season = await db.Seasons + .AsNoTracking() + .Include(item => item.Categories.OrderBy(category => category.SortOrder)) + .ThenInclude(category => category.Candidates) + .FirstOrDefaultAsync(item => item.IsCurrent); + + if (season is null) + { + return Results.NotFound(); + } + + if (siteSettings is null) + { + return Results.Problem("Site settings are missing."); + } + + var winnerPreviewRows = await db.Results + .AsNoTracking() + .Include(result => result.Season) + .Include(result => result.Candidate) + .Where(result => result.Season.Year < season.Year) + .OrderByDescending(result => result.Season.Year) + .ThenBy(result => result.CategoryName) + .Take(8) + .Select(result => new + { + Year = result.Season.Year, + result.CategoryName, + WinnerName = result.Candidate.DisplayName, + WinnerSlug = result.Candidate.ChannelSlug, + WinnerPlatform = result.Candidate.Platform, + }) + .ToArrayAsync(); + + var winnerPreviewItems = winnerPreviewRows + .Select(result => new WinnerPreviewDto( + result.Year, + result.CategoryName, + result.WinnerName, + result.WinnerSlug, + result.WinnerPlatform, + SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug))) + .ToArray(); + + var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase); + var publicCategories = season.Categories + .Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count)) + .ToArray(); + var response = new OverviewResponse( + season.Id, + season.Year, + season.Name, + season.ShowDate, + season.ShowStartsAt, + SeasonMappings.NormalizeSeasonStreamUrl(season.ShowStreamUrl), + season.CurrentPhase, + season.IsCommunityOnly, + "Twitch", + new[] + { + new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, SeasonMappings.ResolveTimelineState("nomination", phaseKey)), + new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, SeasonMappings.ResolveTimelineState("voting", phaseKey)), + new TimelineItem("review", "Review & Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("review", phaseKey)), + new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)), + }, + publicCategories + .Select(category => new FeaturedCategoryDto( + category.Id, + category.GroupName, + category.Name, + category.Description, + category.MaxNomineesPerUser)) + .ToArray(), + winnerPreviewItems, + new PublicSiteContentDto( + siteSettings.HostDisplayName, + siteSettings.HostTagline, + siteSettings.NewsletterUrl, + siteSettings.PrivacyEmail, + siteSettings.PrivacyPolicyContent, + SeasonMappings.ReadSocialLinks(siteSettings), + SeasonMappings.BuildFooterLinks(siteSettings)), + SeasonMappings.ReadFaqItems(siteSettings)); + + return Results.Ok(response); + } +} diff --git a/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs b/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs new file mode 100644 index 0000000..a6c1e4d --- /dev/null +++ b/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs @@ -0,0 +1,71 @@ +using Backend.Contracts; +using Backend.Data; +using Backend.Common; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task GetSeasonCategories(int year, AwardsDbContext db) + { + var season = await db.Seasons + .AsNoTracking() + .Include(item => item.Categories.OrderBy(category => category.SortOrder)) + .ThenInclude(category => category.Candidates.OrderBy(candidate => candidate.DisplayName)) + .FirstOrDefaultAsync(item => item.Year == year); + + if (season is null) + { + return Results.NotFound(); + } + + var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase); + var publicCategories = season.Categories + .Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count)) + .ToArray(); + var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray(); + var approvedClips = await db.ClipSubmissions + .AsNoTracking() + .Where(item => + item.SeasonId == season.Id + && item.Status == "approved" + && item.CategoryId != null + && publicCategoryIds.Contains(item.CategoryId.Value)) + .Select(item => new PublicCandidateClip( + item.CategoryId, + item.CandidateId, + item.Creator, + item.ClipUrl, + item.Title, + item.Platform, + item.CreatedAt, + item.ReviewedAt)) + .ToArrayAsync(); + var clipsByCandidateId = BuildCandidateClipLookup(approvedClips); + var clipsByCreatorKey = BuildCreatorClipLookup(approvedClips); + + return Results.Ok(new SeasonCategoriesResponse( + season.Id, + season.Year, + publicCategories.Select(category => new PublicCategoryDetailDto( + category.Id, + category.Name, + category.GroupName, + category.Description, + category.MaxNomineesPerUser, + category.Candidates.Select(candidate => + { + var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey); + return new CandidateSummaryDto( + candidate.Id, + candidate.DisplayName, + candidate.ChannelSlug, + candidate.Platform, + clip?.ClipUrl, + clip?.Title, + clip?.Platform); + }).ToArray())) + .ToArray())); + } +} diff --git a/Backend/Endpoints/PublicSiteStatusEndpoints.cs b/Backend/Endpoints/PublicSiteStatusEndpoints.cs new file mode 100644 index 0000000..1ab872e --- /dev/null +++ b/Backend/Endpoints/PublicSiteStatusEndpoints.cs @@ -0,0 +1,53 @@ +using Backend.Contracts; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task GetSiteStatus(AwardsDbContext db, IConfiguration configuration) + { + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.Ok(new PublicSiteStatusResponse( + IsDemoLoginEnabled(configuration), + false, + "Sternenpause", + "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.")); + } + + return Results.Ok(new PublicSiteStatusResponse( + ResolveDemoLoginEnabled(settings, configuration), + settings.MaintenanceModeEnabled, + string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? "Sternenpause" : settings.MaintenanceTitle, + string.IsNullOrWhiteSpace(settings.MaintenanceMessage) + ? "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei." + : settings.MaintenanceMessage)); + } + + private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration) + { + var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings); + if (!usesDatabaseDemo) + { + return IsDemoLoginEnabled(configuration); + } + + return settings.DemoLoginEnabled && HasDatabaseDemoCredentials(settings); + } + + private static bool IsDemoLoginEnabled(IConfiguration configuration) + { + var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"] + ?? configuration["DemoAdmin:Enabled"]; + + return bool.TryParse(rawValue, out var enabled) && enabled; + } + + private static bool HasDatabaseDemoCredentials(Backend.Domain.SiteSettings settings) => + !string.IsNullOrWhiteSpace(settings.DemoLoginEmail) + && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash) + && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt); +} diff --git a/Backend/Endpoints/PublicUserParticipationEndpoints.cs b/Backend/Endpoints/PublicUserParticipationEndpoints.cs new file mode 100644 index 0000000..c85d11b --- /dev/null +++ b/Backend/Endpoints/PublicUserParticipationEndpoints.cs @@ -0,0 +1,89 @@ +using Backend.Contracts; +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task GetUserParticipation( + HttpContext context, + int year, + AwardsDbContext db, + IUserSessionService userSessionService) + { + var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); + if (session is null) + { + return Results.Unauthorized(); + } + + var season = await db.Seasons + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Year == year); + + if (season is null) + { + return Results.NotFound(); + } + + var nominations = await db.Nominations + .AsNoTracking() + .Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId) + .OrderBy(item => item.CategoryId) + .ThenBy(item => item.Id) + .Select(item => new + { + item.CategoryId, + item.Status, + Nominee = item.CandidateId != null + ? item.Candidate!.DisplayName + : item.CandidateText, + }) + .ToArrayAsync(); + + var groupedNominations = nominations + .Where(item => item.Status != "rejected" && item.Status != "superseded") + .Where(item => !string.IsNullOrWhiteSpace(item.Nominee)) + .GroupBy(item => item.CategoryId) + .Select(group => new UserNominationStateDto( + group.Key, + group.Select(item => item.Nominee!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray())) + .ToArray(); + + var votes = await db.VoteEntries + .AsNoTracking() + .Where(item => item.Ballot.SeasonId == season.Id && item.Ballot.SubmittedByTwitchId == session.TwitchUserId) + .OrderBy(item => item.CategoryId) + .Select(item => new UserVoteStateDto(item.CategoryId, item.CandidateId)) + .ToArrayAsync(); + + var clips = await db.ClipSubmissions + .AsNoTracking() + .Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId) + .OrderByDescending(item => item.CreatedAt) + .Take(12) + .Select(item => new UserClipSubmissionStateDto( + item.Id, + item.CategoryId, + item.ClipUrl, + item.Title, + item.Creator, + item.Platform, + item.Status, + item.CreatedAt, + item.ReviewNote, + item.ReviewedAt)) + .ToArrayAsync(); + + return Results.Ok(new UserParticipationResponse( + season.Id, + season.Year, + groupedNominations, + votes, + clips)); + } +} diff --git a/Backend/Endpoints/PublicVoteEndpoints.cs b/Backend/Endpoints/PublicVoteEndpoints.cs new file mode 100644 index 0000000..35f1d38 --- /dev/null +++ b/Backend/Endpoints/PublicVoteEndpoints.cs @@ -0,0 +1,145 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task CreateVote( + HttpContext context, + CreateVoteRequest request, + AwardsDbContext db, + IUserSessionService userSessionService, + IRiskFlagService riskFlagService, + IRiskRuleService riskRuleService) + { + if (request.Entries.Length == 0) + { + return Results.BadRequest(new { message = "At least one vote entry is required." }); + } + + var distinctCategoryCount = request.Entries + .Select(item => item.CategoryId) + .Distinct() + .Count(); + + if (distinctCategoryCount != request.Entries.Length) + { + return Results.BadRequest(new { message = "Only one vote entry per category is allowed." }); + } + + var season = await db.Seasons + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == request.SeasonId); + var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting"); + if (voteSeasonResolution.Result is not null) + { + return voteSeasonResolution.Result; + } + + var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService); + if (submitterIdResult.Result is not null) + { + return submitterIdResult.Result; + } + + var submitterId = submitterIdResult.SubmitterId!; + var requestMetadata = RequestMetadataReader.Read(context); + var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray(); + var validCandidates = await db.Candidates + .AsNoTracking() + .Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id)) + .Select(item => new { item.Id, item.CategoryId }) + .ToArrayAsync(); + + if (validCandidates.Length != candidateIds.Length) + { + return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." }); + } + + var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId); + if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId)) + { + return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." }); + } + + var ballot = await db.VoteBallots + .Include(item => item.Entries) + .FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId); + + var isResubmission = ballot is not null; + if (ballot is null) + { + ballot = new VoteBallot + { + SeasonId = request.SeasonId, + SubmittedByTwitchId = submitterId, + }; + + await db.VoteBallots.AddAsync(ballot); + } + else + { + db.VoteEntries.RemoveRange(ballot.Entries); + ballot.Entries.Clear(); + } + + ballot.SubmittedAt = DateTimeOffset.UtcNow; + ballot.Status = "submitted"; + ballot.Entries = request.Entries.Select(entry => new VoteEntry + { + CategoryId = entry.CategoryId, + CandidateId = entry.CandidateId, + }).ToList(); + + var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted); + var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted); + var recentVoteSubmissions = await db.VoteBallots.CountAsync(item => + item.SubmittedByTwitchId == submitterId + && item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes)); + + await db.SaveChangesAsync(context.RequestAborted); + var ballotLink = new + { + label = "Voting-Analytics öffnen", + entityType = "vote", + entityId = ballot.Id.ToString(), + to = $"/admin/analytics?query={Uri.EscapeDataString(submitterId)}", + }; + + if (isResubmission && resubmittedBallotRule.Enabled) + { + await riskFlagService.AddIfMissingAsync( + request.SeasonId, + submitterId, + "vote", + "resubmitted_ballot", + resubmittedBallotRule.Severity, + "Ein User hat sein Ballot erneut gespeichert oder aktualisiert.", + requestMetadata, + new { ballotId = ballot.Id, entryCount = request.Entries.Length, entityLinks = new[] { ballotLink } }, + context.RequestAborted); + } + + if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold) + { + await riskFlagService.AddIfMissingAsync( + request.SeasonId, + submitterId, + "vote", + "rapid_vote_updates", + rapidVoteUpdatesRule.Severity, + "Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.", + requestMetadata, + new { ballotId = ballot.Id, recentVoteSubmissions, threshold = rapidVoteUpdatesRule.Threshold, windowMinutes = rapidVoteUpdatesRule.WindowMinutes, entityLinks = new[] { ballotLink } }, + context.RequestAborted); + } + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission }); + } +} diff --git a/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs new file mode 100644 index 0000000..5b85455 --- /dev/null +++ b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs @@ -0,0 +1,37 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task GetWinnerArchive(int year, AwardsDbContext db) + { + var winnerRows = await db.Results + .AsNoTracking() + .Include(result => result.Candidate) + .Where(result => result.Season.Year == year) + .OrderBy(result => result.CategoryName) + .Select(result => new + { + result.CategoryName, + WinnerName = result.Candidate.DisplayName, + WinnerSlug = result.Candidate.ChannelSlug, + WinnerPlatform = result.Candidate.Platform, + }) + .ToArrayAsync(); + + var items = winnerRows + .Select(result => new WinnerArchiveItemDto( + result.CategoryName, + result.WinnerName, + result.WinnerSlug, + result.WinnerPlatform, + SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug))) + .ToArray(); + + return Results.Ok(new WinnerArchiveResponse(year, items)); + } +} diff --git a/Backend/Endpoints/SystemEndpoints.cs b/Backend/Endpoints/SystemEndpoints.cs new file mode 100644 index 0000000..f15bf68 --- /dev/null +++ b/Backend/Endpoints/SystemEndpoints.cs @@ -0,0 +1,50 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static class SystemEndpoints +{ + public static IEndpointRouteBuilder MapSystemEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })) + .WithName("GetHealth") + .WithOpenApi(); + + app.MapGet("/api/health/database", async (AwardsDbContext db, IConfiguration configuration) => + { + var source = configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings"; + + try + { + var canConnect = await db.Database.CanConnectAsync(); + var pendingMigrations = canConnect + ? await db.Database.GetPendingMigrationsAsync() + : Array.Empty(); + + return Results.Ok(new + { + provider = "postgres", + canConnect, + pendingMigrations, + configuredConnection = new { source }, + }); + } + catch (Exception exception) + { + return Results.Ok(new + { + provider = "postgres", + canConnect = false, + pendingMigrations = Array.Empty(), + configuredConnection = new { source }, + error = exception.Message, + }); + } + }) + .WithName("GetDatabaseHealth") + .WithOpenApi(); + + return app; + } +} diff --git a/Backend/Extensions/ServiceCollectionExtensions.cs b/Backend/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0ca7dbd --- /dev/null +++ b/Backend/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,141 @@ +using System.Threading.RateLimiting; +using Backend.Common; +using Backend.Configuration; +using Backend.Data; +using Backend.Repositories; +using Backend.Security; +using Backend.Services; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using System.Globalization; + +namespace Backend.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices( + this IServiceCollection services, + IConfiguration configuration, + IWebHostEnvironment environment) + { + services.AddProblemDetails(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + + services.Configure(configuration.GetSection(FrontendOptions.SectionName)); + var allowedOrigins = ResolveAllowedOrigins(configuration, environment); + + var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "No PostgreSQL connection string configured. Set VTSA_POSTGRES or ConnectionStrings:Postgres."); + } + + services.AddCors(options => + { + options.AddPolicy(ApplicationDefaults.FrontendCorsPolicy, policy => + { + policy.WithOrigins(allowedOrigins) + .WithHeaders("Authorization", "Content-Type") + .WithMethods(HttpMethods.Get, HttpMethods.Post, HttpMethods.Put, HttpMethods.Delete); + }); + }); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.ContentType = "application/json"; + await context.HttpContext.Response.WriteAsJsonAsync( + new { message = "Zu viele Anfragen. Bitte kurz warten und erneut versuchen." }, + cancellationToken); + }; + + options.AddPolicy(ApplicationDefaults.AuthRateLimitPolicy, context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: BuildRateLimitPartitionKey(context, "auth"), + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true, + })); + + options.AddPolicy(ApplicationDefaults.PublicWriteRateLimitPolicy, context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: BuildRateLimitPartitionKey(context, "public-write"), + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 20, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true, + })); + }); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + private static string BuildRateLimitPartitionKey(HttpContext context, string policyName) + { + var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; + var route = context.Request.Path.Value ?? "/"; + return string.Create( + CultureInfo.InvariantCulture, + $"{policyName}:{ipAddress}:{route}"); + } + + private static string[] ResolveAllowedOrigins(IConfiguration configuration, IWebHostEnvironment environment) + { + var frontendOptions = configuration.GetSection(FrontendOptions.SectionName).Get(); + var configuredOrigins = frontendOptions?.AllowedOrigins + .Select(NormalizeCorsOrigin) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? []; + + if (configuredOrigins.Length > 0) + { + return configuredOrigins; + } + + if (environment.IsDevelopment()) + { + return ApplicationDefaults.FrontendOrigins; + } + + throw new InvalidOperationException( + "Frontend:AllowedOrigins must be configured in non-development environments."); + } + + private static string NormalizeCorsOrigin(string origin) + { + var trimmedOrigin = origin.Trim(); + if (string.IsNullOrWhiteSpace(trimmedOrigin) || trimmedOrigin.Contains('*', StringComparison.Ordinal)) + { + throw new InvalidOperationException("CORS origins must be explicit http(s) origins. Wildcards are not allowed."); + } + + if (!Uri.TryCreate(trimmedOrigin, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https") + || string.IsNullOrWhiteSpace(uri.Host)) + { + throw new InvalidOperationException($"Invalid CORS origin configured: {trimmedOrigin}"); + } + + return uri.GetLeftPart(UriPartial.Authority); + } +} diff --git a/Backend/Extensions/WebApplicationExtensions.cs b/Backend/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..55c2605 --- /dev/null +++ b/Backend/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,84 @@ +using Backend.Common; +using Backend.Data; +using Backend.Endpoints; +using Backend.Security; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Extensions; + +public static class WebApplicationExtensions +{ + public static void UseApplicationPipeline(this WebApplication app) + { + app.UseExceptionHandler(); + + if (!app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseCors(ApplicationDefaults.FrontendCorsPolicy); + app.UseMiddleware(); + app.UseRateLimiter(); + + if (!app.Environment.IsDevelopment()) + { + app.UseHttpsRedirection(); + } + } + + public static async Task InitializeDatabaseAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService() + .CreateLogger("DatabaseInitialization"); + + try + { + if (app.Environment.IsDevelopment()) + { + await db.Database.MigrateAsync(); + } + + await SessionBootstrapper.EnsureAsync(db); + await OperationalTablesBootstrapper.EnsureAsync(db); + if (ShouldSeedPresentationData(app)) + { + await SeedDataBootstrapper.EnsureAsync(db); + } + } + catch (Exception error) + { + logger.LogError(error, "Database initialization failed. Check the PostgreSQL connection, migrations, and seed data."); + throw; + } + } + + public static void MapApplicationEndpoints(this WebApplication app) + { + app.MapSystemEndpoints(); + app.MapAuthEndpoints(); + app.MapPublicEndpoints(); + app.MapAdminEndpoints(); + } + + private static bool ShouldSeedPresentationData(WebApplication app) + { + var mode = app.Configuration["VTSA_SEED_MODE"] + ?? app.Configuration["SeedData:Mode"]; + + if (string.IsNullOrWhiteSpace(mode)) + { + return app.Environment.IsDevelopment(); + } + + return mode.Trim().ToLowerInvariant() is "demo" or "presentation" or "sample"; + } +} diff --git a/Backend/Migrations/20260623112528_AddClipReviewWorkflow.Designer.cs b/Backend/Migrations/20260623112528_AddClipReviewWorkflow.Designer.cs new file mode 100644 index 0000000..7c4bf25 --- /dev/null +++ b/Backend/Migrations/20260623112528_AddClipReviewWorkflow.Designer.cs @@ -0,0 +1,1081 @@ +// +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("20260623112528_AddClipReviewWorkflow")] + partial class AddClipReviewWorkflow + { + /// + 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("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.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("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + 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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("SeasonId") + .HasColumnType("integer"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + 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, + 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, + 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("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("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), + 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), + 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), + 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), + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + 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"); + + 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.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + 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.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/20260623112528_AddClipReviewWorkflow.cs b/Backend/Migrations/20260623112528_AddClipReviewWorkflow.cs new file mode 100644 index 0000000..67917b0 --- /dev/null +++ b/Backend/Migrations/20260623112528_AddClipReviewWorkflow.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddClipReviewWorkflow : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE IF EXISTS "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL; + + ALTER TABLE IF EXISTS "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL; + + ALTER TABLE IF EXISTS "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE IF EXISTS "ClipSubmissions" + DROP COLUMN IF EXISTS "ReviewNote"; + + ALTER TABLE IF EXISTS "ClipSubmissions" + DROP COLUMN IF EXISTS "ReviewedByTwitchId"; + + ALTER TABLE IF EXISTS "ClipSubmissions" + DROP COLUMN IF EXISTS "ReviewedAt"; + """); + } + } +} diff --git a/Backend/Migrations/20260623113917_AddNominationReviewWorkflow.Designer.cs b/Backend/Migrations/20260623113917_AddNominationReviewWorkflow.Designer.cs new file mode 100644 index 0000000..5cd66df --- /dev/null +++ b/Backend/Migrations/20260623113917_AddNominationReviewWorkflow.Designer.cs @@ -0,0 +1,1099 @@ +// +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("20260623113917_AddNominationReviewWorkflow")] + partial class AddNominationReviewWorkflow + { + /// + 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("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.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("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + 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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("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), + 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), + 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), + 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), + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + 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"); + + 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.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + 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.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/20260623113917_AddNominationReviewWorkflow.cs b/Backend/Migrations/20260623113917_AddNominationReviewWorkflow.cs new file mode 100644 index 0000000..662309e --- /dev/null +++ b/Backend/Migrations/20260623113917_AddNominationReviewWorkflow.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddNominationReviewWorkflow : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Nominations_SeasonId", + table: "Nominations"); + + migrationBuilder.AddColumn( + name: "ReviewNote", + table: "Nominations", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "ReviewedAt", + table: "Nominations", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReviewedByTwitchId", + table: "Nominations", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + migrationBuilder.AddColumn( + name: "Status", + table: "Nominations", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "Nominations", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" }, + values: new object[] { null, null, null, "pending" }); + + migrationBuilder.UpdateData( + table: "Nominations", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" }, + values: new object[] { null, null, null, "pending" }); + + migrationBuilder.CreateIndex( + name: "IX_Nominations_SeasonId_Status", + table: "Nominations", + columns: new[] { "SeasonId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Nominations_SeasonId_Status", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "ReviewNote", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "ReviewedAt", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "ReviewedByTwitchId", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "Status", + table: "Nominations"); + + migrationBuilder.CreateIndex( + name: "IX_Nominations_SeasonId", + table: "Nominations", + column: "SeasonId"); + } + } +} diff --git a/Backend/Migrations/20260623131228_AddAwardResultCategoryLock.Designer.cs b/Backend/Migrations/20260623131228_AddAwardResultCategoryLock.Designer.cs new file mode 100644 index 0000000..79467f3 --- /dev/null +++ b/Backend/Migrations/20260623131228_AddAwardResultCategoryLock.Designer.cs @@ -0,0 +1,1119 @@ +// +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("20260623131228_AddAwardResultCategoryLock")] + partial class AddAwardResultCategoryLock + { + /// + 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("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.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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("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), + 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), + 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), + 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), + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + 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"); + + 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.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/20260623131228_AddAwardResultCategoryLock.cs b/Backend/Migrations/20260623131228_AddAwardResultCategoryLock.cs new file mode 100644 index 0000000..07f82dd --- /dev/null +++ b/Backend/Migrations/20260623131228_AddAwardResultCategoryLock.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddAwardResultCategoryLock : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Results_SeasonId", + table: "Results"); + + migrationBuilder.AddColumn( + name: "CategoryId", + table: "Results", + type: "integer", + nullable: true); + + migrationBuilder.Sql( + """ + UPDATE "Results" AS r + SET "CategoryId" = c."Id" + FROM "Categories" AS c + WHERE r."SeasonId" = c."SeasonId" + AND lower(trim(r."CategoryName")) = lower(trim(c."Name")); + + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM "Results" WHERE "CategoryId" IS NULL) THEN + RAISE EXCEPTION 'Could not backfill CategoryId for one or more rows in Results.'; + END IF; + END $$; + """); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "Results", + type: "integer", + nullable: false, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Results_CategoryId", + table: "Results", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Results_SeasonId_CategoryId", + table: "Results", + columns: new[] { "SeasonId", "CategoryId" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Results_Categories_CategoryId", + table: "Results", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Results_Categories_CategoryId", + table: "Results"); + + migrationBuilder.DropIndex( + name: "IX_Results_CategoryId", + table: "Results"); + + migrationBuilder.DropIndex( + name: "IX_Results_SeasonId_CategoryId", + table: "Results"); + + migrationBuilder.DropColumn( + name: "CategoryId", + table: "Results"); + + migrationBuilder.CreateIndex( + name: "IX_Results_SeasonId", + table: "Results", + column: "SeasonId"); + } + } +} diff --git a/Backend/Migrations/20260623152322_AddSeasonShowStreamUrl.Designer.cs b/Backend/Migrations/20260623152322_AddSeasonShowStreamUrl.Designer.cs new file mode 100644 index 0000000..e8fa06c --- /dev/null +++ b/Backend/Migrations/20260623152322_AddSeasonShowStreamUrl.Designer.cs @@ -0,0 +1,1128 @@ +// +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("20260623152322_AddSeasonShowStreamUrl")] + partial class AddSeasonShowStreamUrl + { + /// + 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("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.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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("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), + 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), + 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), + 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), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + 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"); + + 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.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/20260623152322_AddSeasonShowStreamUrl.cs b/Backend/Migrations/20260623152322_AddSeasonShowStreamUrl.cs new file mode 100644 index 0000000..06fef06 --- /dev/null +++ b/Backend/Migrations/20260623152322_AddSeasonShowStreamUrl.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddSeasonShowStreamUrl : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ShowStreamUrl", + table: "Seasons", + type: "character varying(400)", + maxLength: 400, + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 1, + column: "ShowStreamUrl", + value: "https://twitch.tv/jayuhime"); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 2, + column: "ShowStreamUrl", + value: "https://twitch.tv/jayuhime"); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 3, + column: "ShowStreamUrl", + value: "https://youtube.com/c/Jayuhime"); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 4, + column: "ShowStreamUrl", + value: "https://twitch.tv/jayuhime"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ShowStreamUrl", + table: "Seasons"); + } + } +} diff --git a/Backend/Migrations/20260623153212_AddSiteSettings.Designer.cs b/Backend/Migrations/20260623153212_AddSiteSettings.Designer.cs new file mode 100644 index 0000000..ff76a51 --- /dev/null +++ b/Backend/Migrations/20260623153212_AddSiteSettings.Designer.cs @@ -0,0 +1,1199 @@ +// +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("20260623153212_AddSiteSettings")] + partial class AddSiteSettings + { + /// + 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("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.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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("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), + 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), + 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), + 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), + 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("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + 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("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ContactUrl = "https://vtuber-star-awards.de/kontakt", + FaqJson = "[{\"question\":\"Wer darf mitmachen?\",\"answer\":\"Jede:r mit einem Twitch-Account. Einmal einloggen gen\\u00FCgt \\u2013 kein extra Konto, kein Papierkram.\"},{\"question\":\"Wie werden die Gewinner bestimmt?\",\"answer\":\"Komplett durch eure Stimmen. Die Community entscheidet, wer auf die B\\u00FChne darf.\"},{\"question\":\"Kann ich meine Wahl noch \\u00E4ndern?\",\"answer\":\"Ja. Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen anpassen.\"},{\"question\":\"Wann und wo findet die Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und live auf den verkn\\u00FCpften Plattformen \\u00FCbertragen.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\"},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\"},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\"},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\"},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\"}]", + SponsorsUrl = "https://vtuber-star-awards.de/partner" + }); + }); + + 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"); + + 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.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/20260623153212_AddSiteSettings.cs b/Backend/Migrations/20260623153212_AddSiteSettings.cs new file mode 100644 index 0000000..2840759 --- /dev/null +++ b/Backend/Migrations/20260623153212_AddSiteSettings.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddSiteSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SiteSettings", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + HostDisplayName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + HostTagline = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + NewsletterUrl = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + PrivacyEmail = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + ImprintUrl = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + ContactUrl = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + SponsorsUrl = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + SocialLinksJson = table.Column(type: "text", nullable: false), + FaqJson = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SiteSettings", x => x.Id); + }); + + migrationBuilder.InsertData( + table: "SiteSettings", + columns: new[] { "Id", "ContactUrl", "FaqJson", "HostDisplayName", "HostTagline", "ImprintUrl", "NewsletterUrl", "PrivacyEmail", "SocialLinksJson", "SponsorsUrl" }, + values: new object[] { 1, "https://vtuber-star-awards.de/kontakt", "[{\"question\":\"Wer darf mitmachen?\",\"answer\":\"Jede:r mit einem Twitch-Account. Einmal einloggen gen\\u00FCgt \\u2013 kein extra Konto, kein Papierkram.\"},{\"question\":\"Wie werden die Gewinner bestimmt?\",\"answer\":\"Komplett durch eure Stimmen. Die Community entscheidet, wer auf die B\\u00FChne darf.\"},{\"question\":\"Kann ich meine Wahl noch \\u00E4ndern?\",\"answer\":\"Ja. Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen anpassen.\"},{\"question\":\"Wann und wo findet die Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und live auf den verkn\\u00FCpften Plattformen \\u00FCbertragen.\"}]", "Jayuhime", "VTuber & Award Host", "https://vtuber-star-awards.de/impressum", "https://vtuber-star-awards.de/newsletter", "datenschutz@vtuber-star-awards.de", "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\"},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\"},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\"},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\"},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\"}]", "https://vtuber-star-awards.de/partner" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SiteSettings"); + } + } +} diff --git a/Backend/Migrations/20260623154438_AddPrivacyPolicyContentMetadata.Designer.cs b/Backend/Migrations/20260623154438_AddPrivacyPolicyContentMetadata.Designer.cs new file mode 100644 index 0000000..cd5b793 --- /dev/null +++ b/Backend/Migrations/20260623154438_AddPrivacyPolicyContentMetadata.Designer.cs @@ -0,0 +1,1213 @@ +// +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("20260623154438_AddPrivacyPolicyContentMetadata")] + partial class AddPrivacyPolicyContentMetadata + { + /// + 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("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.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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("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), + 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), + 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), + 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), + 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("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + 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("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + 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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ContactUrl = "https://vtuber-star-awards.de/kontakt", + FaqJson = "[{\"question\":\"Wer darf mitmachen?\",\"answer\":\"Jede:r mit einem Twitch-Account. Einmal einloggen gen\\u00FCgt \\u2013 kein extra Konto, kein Papierkram.\"},{\"question\":\"Wie werden die Gewinner bestimmt?\",\"answer\":\"Komplett durch eure Stimmen. Die Community entscheidet, wer auf die B\\u00FChne darf.\"},{\"question\":\"Kann ich meine Wahl noch \\u00E4ndern?\",\"answer\":\"Ja. Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen anpassen.\"},{\"question\":\"Wann und wo findet die Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und live auf den verkn\\u00FCpften Plattformen \\u00FCbertragen.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + 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", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\"},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\"},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\"},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\"},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\"}]", + SponsorsUrl = "https://vtuber-star-awards.de/partner" + }); + }); + + 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"); + + 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.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/20260623154438_AddPrivacyPolicyContentMetadata.cs b/Backend/Migrations/20260623154438_AddPrivacyPolicyContentMetadata.cs new file mode 100644 index 0000000..de3d9e0 --- /dev/null +++ b/Backend/Migrations/20260623154438_AddPrivacyPolicyContentMetadata.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddPrivacyPolicyContentMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PrivacyPolicyContent", + table: "SiteSettings", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "PrivacyPolicyUpdatedAt", + table: "SiteSettings", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrivacyPolicyUpdatedBy", + table: "SiteSettings", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "PrivacyPolicyContent", "PrivacyPolicyUpdatedAt", "PrivacyPolicyUpdatedBy" }, + values: new object[] { "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.", new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "seed" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PrivacyPolicyContent", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "PrivacyPolicyUpdatedAt", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "PrivacyPolicyUpdatedBy", + table: "SiteSettings"); + } + } +} diff --git a/Backend/Migrations/20260624065545_AddOperationalSiteSettings.Designer.cs b/Backend/Migrations/20260624065545_AddOperationalSiteSettings.Designer.cs new file mode 100644 index 0000000..b5de68c --- /dev/null +++ b/Backend/Migrations/20260624065545_AddOperationalSiteSettings.Designer.cs @@ -0,0 +1,1267 @@ +// +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("20260624065545_AddOperationalSiteSettings")] + partial class AddOperationalSiteSettings + { + /// + 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("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.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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("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), + 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), + 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), + 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), + 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("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("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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + 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", + 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", + 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}]", + SponsorsUrl = "https://vtuber-star-awards.de/partner" + }); + }); + + 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"); + + 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.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/20260624065545_AddOperationalSiteSettings.cs b/Backend/Migrations/20260624065545_AddOperationalSiteSettings.cs new file mode 100644 index 0000000..c6b1e0b --- /dev/null +++ b/Backend/Migrations/20260624065545_AddOperationalSiteSettings.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddOperationalSiteSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DemoLoginDisplayName", + table: "SiteSettings", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "DemoLoginEmail", + table: "SiteSettings", + type: "character varying(180)", + maxLength: 180, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "DemoLoginEnabled", + table: "SiteSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DemoLoginManagedByDatabase", + table: "SiteSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DemoLoginPasswordHash", + table: "SiteSettings", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "DemoLoginPasswordSalt", + table: "SiteSettings", + type: "character varying(80)", + maxLength: 80, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "DemoLoginTwitchUserId", + table: "SiteSettings", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "MaintenanceMessage", + table: "SiteSettings", + type: "character varying(600)", + maxLength: 600, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "MaintenanceModeEnabled", + table: "SiteSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MaintenanceTitle", + table: "SiteSettings", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "DemoLoginDisplayName", "DemoLoginEmail", "DemoLoginEnabled", "DemoLoginManagedByDatabase", "DemoLoginPasswordHash", "DemoLoginPasswordSalt", "DemoLoginTwitchUserId", "MaintenanceMessage", "MaintenanceModeEnabled", "MaintenanceTitle" }, + values: new object[] { "Jayuhime Admin", "", false, false, "", "", "jayuhime_admin", "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", false, "Sternenpause" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DemoLoginDisplayName", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "DemoLoginEmail", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "DemoLoginEnabled", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "DemoLoginManagedByDatabase", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "DemoLoginPasswordHash", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "DemoLoginPasswordSalt", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "DemoLoginTwitchUserId", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "MaintenanceMessage", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "MaintenanceModeEnabled", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "MaintenanceTitle", + table: "SiteSettings"); + + } + } +} diff --git a/Backend/Migrations/20260624133211_AddSeasonShowStartsAt.Designer.cs b/Backend/Migrations/20260624133211_AddSeasonShowStartsAt.Designer.cs new file mode 100644 index 0000000..43b2ad4 --- /dev/null +++ b/Backend/Migrations/20260624133211_AddSeasonShowStartsAt.Designer.cs @@ -0,0 +1,1274 @@ +// +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("20260624133211_AddSeasonShowStartsAt")] + partial class AddSeasonShowStartsAt + { + /// + 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("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.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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("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("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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + 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", + 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", + 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}]", + SponsorsUrl = "https://vtuber-star-awards.de/partner" + }); + }); + + 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"); + + 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.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/20260624133211_AddSeasonShowStartsAt.cs b/Backend/Migrations/20260624133211_AddSeasonShowStartsAt.cs new file mode 100644 index 0000000..d9e45c3 --- /dev/null +++ b/Backend/Migrations/20260624133211_AddSeasonShowStartsAt.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddSeasonShowStartsAt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE "Seasons" + ADD COLUMN IF NOT EXISTS "ShowStartsAt" time without time zone NOT NULL DEFAULT TIME '20:00:00'; + """); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 1, + column: "ShowStartsAt", + value: new TimeOnly(20, 0, 0)); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 2, + column: "ShowStartsAt", + value: new TimeOnly(20, 0, 0)); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 3, + column: "ShowStartsAt", + value: new TimeOnly(20, 0, 0)); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 4, + column: "ShowStartsAt", + value: new TimeOnly(20, 0, 0)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ShowStartsAt", + table: "Seasons"); + } + } +} diff --git a/Backend/Migrations/20260624134432_AddClipCandidateLink.Designer.cs b/Backend/Migrations/20260624134432_AddClipCandidateLink.Designer.cs new file mode 100644 index 0000000..0e53fcb --- /dev/null +++ b/Backend/Migrations/20260624134432_AddClipCandidateLink.Designer.cs @@ -0,0 +1,1289 @@ +// +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("20260624134432_AddClipCandidateLink")] + partial class AddClipCandidateLink + { + /// + 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("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.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("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + 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, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + 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("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("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("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("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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + 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", + 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", + 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}]", + SponsorsUrl = "https://vtuber-star-awards.de/partner" + }); + }); + + 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"); + + 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/20260624134432_AddClipCandidateLink.cs b/Backend/Migrations/20260624134432_AddClipCandidateLink.cs new file mode 100644 index 0000000..3e4aac3 --- /dev/null +++ b/Backend/Migrations/20260624134432_AddClipCandidateLink.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddClipCandidateLink : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE IF EXISTS "ClipSubmissions" + ADD COLUMN IF NOT EXISTS "CandidateId" integer NULL; + + DO $$ + BEGIN + IF to_regclass('"ClipSubmissions"') IS NOT NULL THEN + CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId" + ON "ClipSubmissions" ("CandidateId"); + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId' + ) THEN + ALTER TABLE "ClipSubmissions" + ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId" + FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id") + ON DELETE SET NULL; + END IF; + END IF; + END $$; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE IF EXISTS "ClipSubmissions" + DROP CONSTRAINT IF EXISTS "FK_ClipSubmissions_Candidates_CandidateId"; + + DROP INDEX IF EXISTS "IX_ClipSubmissions_CandidateId"; + + ALTER TABLE IF EXISTS "ClipSubmissions" + DROP COLUMN IF EXISTS "CandidateId"; + """); + } + } +} diff --git a/Backend/Migrations/20260624150500_AddRiskFlagReviewNote.cs b/Backend/Migrations/20260624150500_AddRiskFlagReviewNote.cs new file mode 100644 index 0000000..1ad1bfe --- /dev/null +++ b/Backend/Migrations/20260624150500_AddRiskFlagReviewNote.cs @@ -0,0 +1,34 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + [DbContext(typeof(AwardsDbContext))] + [Migration("20260624150500_AddRiskFlagReviewNote")] + public partial class AddRiskFlagReviewNote : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE IF EXISTS "RiskFlags" + ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE IF EXISTS "RiskFlags" + DROP COLUMN IF EXISTS "ReviewNote"; + """); + } + } +} diff --git a/Backend/Migrations/20260624162000_AddRiskRulesJson.cs b/Backend/Migrations/20260624162000_AddRiskRulesJson.cs new file mode 100644 index 0000000..275c859 --- /dev/null +++ b/Backend/Migrations/20260624162000_AddRiskRulesJson.cs @@ -0,0 +1,39 @@ +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + [DbContext(typeof(AwardsDbContext))] + [Migration("20260624162000_AddRiskRulesJson")] + public partial class AddRiskRulesJson : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + $""" + ALTER TABLE IF EXISTS "SiteSettings" + ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '{RiskRuleSettings.Serialize(RiskRuleSettings.Defaults).Replace("'", "''")}'; + + UPDATE "SiteSettings" + SET "RiskRulesJson" = '{RiskRuleSettings.Serialize(RiskRuleSettings.Defaults).Replace("'", "''")}' + WHERE "RiskRulesJson" IS NULL OR btrim("RiskRulesJson") = '' OR "RiskRulesJson" = '[]'; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE IF EXISTS "SiteSettings" + DROP COLUMN IF EXISTS "RiskRulesJson"; + """); + } + } +} diff --git a/Backend/Migrations/AwardsDbContextModelSnapshot.cs b/Backend/Migrations/AwardsDbContextModelSnapshot.cs index 3c69d5d..ce10182 100644 --- a/Backend/Migrations/AwardsDbContextModelSnapshot.cs +++ b/Backend/Migrations/AwardsDbContextModelSnapshot.cs @@ -22,6 +22,51 @@ namespace Backend.Migrations 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("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.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + modelBuilder.Entity("Backend.Domain.AwardResult", b => { b.Property("Id") @@ -33,6 +78,9 @@ namespace Backend.Migrations b.Property("CandidateId") .HasColumnType("integer"); + b.Property("CategoryId") + .HasColumnType("integer"); + b.Property("CategoryName") .IsRequired() .HasMaxLength(120) @@ -45,7 +93,10 @@ namespace Backend.Migrations b.HasIndex("CandidateId"); - b.HasIndex("SeasonId"); + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); b.ToTable("Results"); @@ -54,6 +105,7 @@ namespace Backend.Migrations { Id = 1, CandidateId = 8, + CategoryId = 5, CategoryName = "VTuber des Jahres", SeasonId = 2 }, @@ -61,6 +113,7 @@ namespace Backend.Migrations { Id = 2, CandidateId = 9, + CategoryId = 6, CategoryName = "Bestes Live Event", SeasonId = 2 }, @@ -68,6 +121,7 @@ namespace Backend.Migrations { Id = 3, CandidateId = 10, + CategoryId = 7, CategoryName = "Clip des Jahres", SeasonId = 2 }, @@ -75,6 +129,7 @@ namespace Backend.Migrations { Id = 4, CandidateId = 11, + CategoryId = 8, CategoryName = "VTuber des Jahres", SeasonId = 3 }, @@ -82,6 +137,7 @@ namespace Backend.Migrations { Id = 5, CandidateId = 12, + CategoryId = 9, CategoryName = "Clip des Jahres", SeasonId = 3 }, @@ -89,6 +145,7 @@ namespace Backend.Migrations { Id = 6, CandidateId = 13, + CategoryId = 10, CategoryName = "VTuber des Jahres", SeasonId = 4 }); @@ -407,6 +464,81 @@ namespace Backend.Migrations }); }); + 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") @@ -428,9 +560,25 @@ namespace Backend.Migrations 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("SubmittedByTwitchId") .IsRequired() .HasMaxLength(120) @@ -442,7 +590,7 @@ namespace Backend.Migrations b.HasIndex("CategoryId"); - b.HasIndex("SeasonId"); + b.HasIndex("SeasonId", "Status"); b.ToTable("Nominations"); @@ -454,6 +602,7 @@ namespace Backend.Migrations 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 @@ -463,10 +612,86 @@ namespace Backend.Migrations 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") @@ -506,6 +731,14 @@ namespace Backend.Migrations 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"); @@ -535,6 +768,8 @@ namespace Backend.Migrations 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 @@ -551,6 +786,8 @@ namespace Backend.Migrations 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 @@ -567,6 +804,8 @@ namespace Backend.Migrations 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 @@ -583,12 +822,211 @@ namespace Backend.Migrations 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("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("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("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + 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", + 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 = "[]", + 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}]", + SponsorsUrl = "https://vtuber-star-awards.de/partner" + }); + }); + + 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") @@ -704,6 +1142,12 @@ namespace Backend.Migrations .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") @@ -712,6 +1156,8 @@ namespace Backend.Migrations b.Navigation("Candidate"); + b.Navigation("Category"); + b.Navigation("Season"); }); @@ -745,6 +1191,16 @@ namespace Backend.Migrations 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") @@ -770,6 +1226,15 @@ namespace Backend.Migrations 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") diff --git a/Backend/Program.cs b/Backend/Program.cs index 49ec0e7..ea1d209 100644 --- a/Backend/Program.cs +++ b/Backend/Program.cs @@ -1,1337 +1,13 @@ -using Backend.Contracts; -using Backend.Data; -using Backend.Domain; -using Microsoft.EntityFrameworkCore; -using System.Text.Json; +using Backend.Extensions; var builder = WebApplication.CreateBuilder(args); -var connectionString = builder.Configuration["VTSA_POSTGRES"] - ?? builder.Configuration.GetConnectionString("Postgres"); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -builder.Services.AddCors(options => -{ - options.AddPolicy("frontend", policy => - { - policy - .WithOrigins( - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://localhost:4173", - "http://127.0.0.1:4173") - .AllowAnyHeader() - .AllowAnyMethod(); - }); -}); - -builder.Services.AddDbContext(options => -{ - options.UseNpgsql(connectionString); -}); +builder.Services.AddApplicationServices(builder.Configuration, builder.Environment); var app = builder.Build(); -static string? ReadBearerToken(HttpContext context) -{ - var header = context.Request.Headers.Authorization.ToString(); - return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) - ? header["Bearer ".Length..].Trim() - : null; -} - -static async Task ResolveSessionAsync(HttpContext context, AwardsDbContext db) -{ - var token = ReadBearerToken(context); - if (string.IsNullOrWhiteSpace(token)) - { - return null; - } - - var session = await db.UserSessions.FirstOrDefaultAsync(item => item.SessionToken == token && item.IsActive); - if (session is not null) - { - session.LastSeenAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(); - } - - return session; -} - -static string ReadClientIp(HttpContext context) => - context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - -static string ReadUserAgent(HttpContext context) -{ - var value = context.Request.Headers.UserAgent.ToString().Trim(); - return value.Length > 400 ? value[..400] : value; -} - -static void AddAuditEntry( - AwardsDbContext db, - string adminTwitchUserId, - string actionType, - string entityType, - string entityId, - string summary, - object? metadata = null) -{ - db.AdminAuditEntries.Add(new AdminAuditEntry - { - AdminTwitchUserId = adminTwitchUserId, - ActionType = actionType, - EntityType = entityType, - EntityId = entityId, - Summary = summary, - MetadataJson = JsonSerializer.Serialize(metadata ?? new { }), - CreatedAt = DateTimeOffset.UtcNow, - }); -} - -static async Task AddRiskFlagIfMissingAsync( - AwardsDbContext db, - int? seasonId, - string? twitchUserId, - string source, - string type, - string severity, - string summary, - string createdFromIp, - string userAgent, - object? metadata = null) -{ - var threshold = DateTimeOffset.UtcNow.AddHours(-6); - var exists = await db.RiskFlags.AnyAsync(item => - item.Status == "open" - && item.Source == source - && item.Type == type - && item.TwitchUserId == twitchUserId - && item.CreatedFromIp == createdFromIp - && item.SeasonId == seasonId - && item.CreatedAt >= threshold); - - if (exists) - { - return; - } - - db.RiskFlags.Add(new RiskFlag - { - SeasonId = seasonId, - TwitchUserId = twitchUserId, - Source = source, - Type = type, - Severity = severity, - Status = "open", - Summary = summary, - CreatedFromIp = createdFromIp, - UserAgent = userAgent, - MetadataJson = JsonSerializer.Serialize(metadata ?? new { }), - CreatedAt = DateTimeOffset.UtcNow, - }); -} - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseCors("frontend"); -app.UseHttpsRedirection(); - -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - if (app.Environment.IsDevelopment()) - { - try - { - db.Database.Migrate(); - } - catch - { - // In local environments without PostgreSQL yet, the API should still boot - // so frontend work and migration generation can continue independently. - } - } - - try - { - await SessionBootstrapper.EnsureAsync(db); - await OperationalTablesBootstrapper.EnsureAsync(db); - } - catch - { - // If the operational table bootstrap fails, the rest of the API can still start. - } -} - -app.MapPost("/api/auth/dev-login", async (HttpContext context, LoginRequest request, AwardsDbContext db) => -{ - var createdFromIp = ReadClientIp(context); - var userAgent = ReadUserAgent(context); - var session = new UserSession - { - Id = Guid.NewGuid(), - SessionToken = Guid.NewGuid().ToString("N"), - TwitchUserId = request.TwitchUserId.Trim(), - DisplayName = request.DisplayName.Trim(), - Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer", - CreatedFromIp = createdFromIp, - UserAgent = userAgent, - CreatedAt = DateTimeOffset.UtcNow, - LastSeenAt = DateTimeOffset.UtcNow, - IsActive = true, - }; - - var recentSessionsFromIp = await db.UserSessions.CountAsync(item => - item.CreatedFromIp == createdFromIp - && item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-15)); - - if (recentSessionsFromIp >= 3) - { - await AddRiskFlagIfMissingAsync( - db, - null, - session.TwitchUserId, - "login", - "rapid_login_ip", - "medium", - "Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.", - createdFromIp, - userAgent, - new { recentSessionsFromIp }); - } - - db.UserSessions.Add(session); - await db.SaveChangesAsync(); - - return Results.Ok(new AuthSessionDto( - session.SessionToken, - session.TwitchUserId, - session.DisplayName, - session.Role)); -}) -.WithName("DevLogin") -.WithOpenApi(); - -app.MapGet("/api/auth/session", async (HttpContext context, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session is null) - { - return Results.Unauthorized(); - } - - return Results.Ok(new AuthSessionDto( - session.SessionToken, - session.TwitchUserId, - session.DisplayName, - session.Role)); -}) -.WithName("GetSession") -.WithOpenApi(); - -app.MapPost("/api/auth/logout", async (HttpContext context, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session is null) - { - return Results.Ok(new { loggedOut = true }); - } - - session.IsActive = false; - await db.SaveChangesAsync(); - - return Results.Ok(new { loggedOut = true }); -}) -.WithName("Logout") -.WithOpenApi(); - -app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })) - .WithName("GetHealth") - .WithOpenApi(); - -app.MapGet("/api/health/database", async (AwardsDbContext db) => -{ - try - { - var canConnect = await db.Database.CanConnectAsync(); - var pendingMigrations = canConnect - ? await db.Database.GetPendingMigrationsAsync() - : Array.Empty(); - - return Results.Ok(new - { - provider = "postgres", - canConnect, - pendingMigrations, - configuredConnection = new - { - source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings", - }, - }); - } - catch (Exception exception) - { - return Results.Ok(new - { - provider = "postgres", - canConnect = false, - pendingMigrations = Array.Empty(), - configuredConnection = new - { - source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings", - }, - error = exception.Message, - }); - } -}) -.WithName("GetDatabaseHealth") -.WithOpenApi(); - -app.MapGet("/api/public/overview", async (AwardsDbContext db) => -{ - var season = await db.Seasons - .AsNoTracking() - .Include(item => item.Categories.OrderBy(category => category.SortOrder)) - .Include(item => item.Results) - .ThenInclude(result => result.Candidate) - .FirstOrDefaultAsync(item => item.IsCurrent); - - if (season is null) - { - return Results.NotFound(); - } - - var response = new OverviewResponse( - season.Id, - season.Year, - season.Name, - season.ShowDate, - season.CurrentPhase, - season.IsCommunityOnly, - "Twitch", - new[] - { - new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, "done"), - new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, "active"), - new TimelineItem("review", "Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, "upcoming"), - new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, "upcoming"), - }, - season.Categories - .Take(6) - .Select(category => new FeaturedCategoryDto( - category.Id, - category.GroupName, - category.Name, - category.Description, - category.MaxNomineesPerUser)) - .ToArray(), - season.Results - .OrderByDescending(result => result.SeasonId) - .Take(4) - .Select(result => new WinnerPreviewDto( - season.Year, - result.CategoryName, - result.Candidate.DisplayName, - result.Candidate.ChannelSlug)) - .ToArray(), - new[] - { - new FaqItemDto("Wer kann nominieren und voten?", "Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt."), - new FaqItemDto("Wie werden Gewinner bestimmt?", "Aktuell rein community-basiert. Eine Mischlogik mit Jury oder Panel kann spaeter eingefuehrt werden."), - new FaqItemDto("Wer verwaltet Kategorien und Unterkategorien?", "Das Team pflegt diese pro Jahr im Admin-Bereich."), - }); - - return Results.Ok(response); -}) -.WithName("GetOverview") -.WithOpenApi(); - -app.MapGet("/api/public/seasons/{year:int}/categories", async (int year, AwardsDbContext db) => -{ - var season = await db.Seasons - .AsNoTracking() - .Include(item => item.Categories.OrderBy(category => category.SortOrder)) - .ThenInclude(category => category.Candidates.OrderBy(candidate => candidate.DisplayName)) - .FirstOrDefaultAsync(item => item.Year == year); - - if (season is null) - { - return Results.NotFound(); - } - - return Results.Ok(new SeasonCategoriesResponse( - season.Id, - season.Year, - season.Categories.Select(category => new PublicCategoryDetailDto( - category.Id, - category.Name, - category.GroupName, - category.Description, - category.MaxNomineesPerUser, - category.Candidates.Select(candidate => new CandidateSummaryDto( - candidate.Id, - candidate.DisplayName, - candidate.ChannelSlug, - candidate.Platform)) - .ToArray())) - .ToArray())); -}) -.WithName("GetSeasonCategories") -.WithOpenApi(); - -app.MapGet("/api/public/seasons/{year:int}/winners", async (int year, AwardsDbContext db) => -{ - var items = await db.Results - .AsNoTracking() - .Include(result => result.Candidate) - .Where(result => result.Season.Year == year) - .OrderBy(result => result.CategoryName) - .Select(result => new WinnerArchiveItemDto( - result.CategoryName, - result.Candidate.DisplayName, - result.Candidate.ChannelSlug)) - .ToArrayAsync(); - - return Results.Ok(new WinnerArchiveResponse(year, items)); -}) -.WithName("GetWinnerArchive") -.WithOpenApi(); - -app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominationRequest request, AwardsDbContext db) => -{ - if (request.Nominees.Length is 0 or > 3) - { - return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." }); - } - - var distinctNominees = request.Nominees - .Select(item => item.Trim()) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (distinctNominees.Length != request.Nominees.Length) - { - return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." }); - } - - var category = await db.Categories - .Include(item => item.Season) - .FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year); - - if (category is null) - { - return Results.BadRequest(new { message = "The selected category does not exist for this season." }); - } - - var session = await ResolveSessionAsync(context, db); - var submitterId = session?.TwitchUserId ?? request.TwitchUserId; - if (string.IsNullOrWhiteSpace(submitterId)) - { - return Results.BadRequest(new { message = "A logged in user is required to submit nominations." }); - } - - var createdFromIp = ReadClientIp(context); - var userAgent = ReadUserAgent(context); - var existingNominationCount = await db.Nominations.CountAsync(item => - item.SeasonId == category.SeasonId - && item.CategoryId == category.Id - && item.SubmittedByTwitchId == submitterId); - - var previousNominations = await db.Nominations - .Where(item => - item.SeasonId == category.SeasonId - && item.CategoryId == category.Id - && item.SubmittedByTwitchId == submitterId) - .ToArrayAsync(); - - if (previousNominations.Length > 0) - { - db.Nominations.RemoveRange(previousNominations); - } - - var records = distinctNominees.Select(name => new Nomination - { - SeasonId = category.SeasonId, - CategoryId = category.Id, - SubmittedByTwitchId = submitterId, - CandidateText = name, - CreatedAt = DateTimeOffset.UtcNow, - }); - - await db.Nominations.AddRangeAsync(records); - - var recentNominationVolume = await db.Nominations.CountAsync(item => - item.SubmittedByTwitchId == submitterId - && item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-10)); - - if (existingNominationCount > 0) - { - await AddRiskFlagIfMissingAsync( - db, - category.SeasonId, - submitterId, - "nomination", - "resubmitted_nomination", - "low", - "Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.", - createdFromIp, - userAgent, - new { categoryId = category.Id, existingNominationCount }); - } - - if (recentNominationVolume >= 10) - { - await AddRiskFlagIfMissingAsync( - db, - category.SeasonId, - submitterId, - "nomination", - "rapid_nomination_burst", - "high", - "Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.", - createdFromIp, - userAgent, - new { recentNominationVolume }); - } - - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = distinctNominees.Length, category = category.Name, replacedPrevious = previousNominations.Length > 0 }); -}) -.WithName("CreateNomination") -.WithOpenApi(); - -app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest request, AwardsDbContext db) => -{ - if (request.Entries.Length == 0) - { - return Results.BadRequest(new { message = "At least one vote entry is required." }); - } - - var distinctCategoryCount = request.Entries - .Select(item => item.CategoryId) - .Distinct() - .Count(); - - if (distinctCategoryCount != request.Entries.Length) - { - return Results.BadRequest(new { message = "Only one vote entry per category is allowed." }); - } - - var session = await ResolveSessionAsync(context, db); - var submitterId = session?.TwitchUserId ?? request.TwitchUserId; - if (string.IsNullOrWhiteSpace(submitterId)) - { - return Results.BadRequest(new { message = "A logged in user is required to submit votes." }); - } - - var createdFromIp = ReadClientIp(context); - var userAgent = ReadUserAgent(context); - var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray(); - var validCandidates = await db.Candidates - .AsNoTracking() - .Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id)) - .Select(item => new { item.Id, item.CategoryId }) - .ToArrayAsync(); - - if (validCandidates.Length != candidateIds.Length) - { - return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." }); - } - - var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId); - if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId)) - { - return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." }); - } - - var ballot = await db.VoteBallots - .Include(item => item.Entries) - .FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId); - - var isResubmission = ballot is not null; - if (ballot is null) - { - ballot = new VoteBallot - { - SeasonId = request.SeasonId, - SubmittedByTwitchId = submitterId, - }; - - await db.VoteBallots.AddAsync(ballot); - } - else - { - db.VoteEntries.RemoveRange(ballot.Entries); - ballot.Entries.Clear(); - } - - ballot.SubmittedAt = DateTimeOffset.UtcNow; - ballot.Status = "submitted"; - ballot.Entries = request.Entries.Select(entry => new VoteEntry - { - CategoryId = entry.CategoryId, - CandidateId = entry.CandidateId, - }).ToList(); - - var recentVoteSubmissions = await db.VoteBallots.CountAsync(item => - item.SubmittedByTwitchId == submitterId - && item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-10)); - - if (isResubmission) - { - await AddRiskFlagIfMissingAsync( - db, - request.SeasonId, - submitterId, - "vote", - "resubmitted_ballot", - "low", - "Ein User hat sein Ballot erneut gespeichert oder aktualisiert.", - createdFromIp, - userAgent, - new { entryCount = request.Entries.Length }); - } - - if (recentVoteSubmissions >= 3) - { - await AddRiskFlagIfMissingAsync( - db, - request.SeasonId, - submitterId, - "vote", - "rapid_vote_updates", - "high", - "Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.", - createdFromIp, - userAgent, - new { recentVoteSubmissions }); - } - - await db.SaveChangesAsync(); - - return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission }); -}) -.WithName("CreateVote") -.WithOpenApi(); - -app.MapPost("/api/public/clips", async (HttpContext context, CreateClipRequest request, AwardsDbContext db) => -{ - var clipUrl = request.ClipUrl?.Trim() ?? string.Empty; - if (string.IsNullOrWhiteSpace(clipUrl)) - { - return Results.BadRequest(new { message = "A clip link is required." }); - } - - var loweredUrl = clipUrl.ToLowerInvariant(); - var platform = loweredUrl.Contains("twitch.tv") - ? "Twitch" - : loweredUrl.Contains("youtube.com") || loweredUrl.Contains("youtu.be") - ? "YouTube" - : "Other"; - - if (platform == "Other") - { - return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." }); - } - - var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year); - if (season is null) - { - return Results.BadRequest(new { message = "The selected season does not exist." }); - } - - if (request.CategoryId is int categoryId) - { - var categoryExists = await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id); - if (!categoryExists) - { - return Results.BadRequest(new { message = "The selected category does not exist for this season." }); - } - } - - var session = await ResolveSessionAsync(context, db); - var submitterId = session?.TwitchUserId ?? request.TwitchUserId; - if (string.IsNullOrWhiteSpace(submitterId)) - { - return Results.BadRequest(new { message = "A logged in user is required to submit clips." }); - } - - var clip = new ClipSubmission - { - SeasonId = season.Id, - CategoryId = request.CategoryId, - SubmittedByTwitchId = submitterId, - ClipUrl = clipUrl, - Title = request.Title?.Trim() ?? string.Empty, - Creator = request.Creator?.Trim() ?? string.Empty, - Platform = platform, - Status = "pending", - CreatedFromIp = ReadClientIp(context), - CreatedAt = DateTimeOffset.UtcNow, - }; - - db.ClipSubmissions.Add(clip); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, clipId = clip.Id }); -}) -.WithName("CreateClip") -.WithOpenApi(); - -app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent); - if (currentSeason is null) - { - return Results.NotFound(); - } - - var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id); - var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id); - var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id); - var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null); - - var topCategoryNames = await db.VoteEntries - .AsNoTracking() - .Where(item => item.Ballot.SeasonId == currentSeason.Id) - .Select(item => item.Category.Name) - .ToListAsync(); - - var topCategories = topCategoryNames - .GroupBy(name => name) - .Select(group => new AdminTopCategoryDto(group.Key, group.Count())) - .OrderByDescending(item => item.Votes) - .Take(5) - .ToArray(); - - var riskFlags = await db.RiskFlags - .AsNoTracking() - .Where(item => item.Status == "open") - .OrderByDescending(item => item.CreatedAt) - .Take(8) - .Select(item => new AdminRiskFlagDto( - item.Id, - item.Source, - item.Type, - item.Severity, - item.Status, - item.Summary, - item.TwitchUserId, - item.CreatedFromIp, - item.CreatedAt, - item.MetadataJson)) - .ToArrayAsync(); - - var auditEntries = await db.AdminAuditEntries - .AsNoTracking() - .OrderByDescending(item => item.CreatedAt) - .Take(8) - .Select(item => new AdminAuditEntryDto( - item.Id, - item.AdminTwitchUserId, - item.ActionType, - item.EntityType, - item.EntityId, - item.Summary, - item.CreatedAt)) - .ToArrayAsync(); - - var activityItems = auditEntries - .Take(3) - .Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min.")) - .ToArray(); - - var response = new AdminDashboardResponse( - new[] - { - new AdminMetricDto("Nominierungen", nominationCount, "+12.4% vs. gestern"), - new AdminMetricDto("Stimmen", voteCount, "+8.7% vs. gestern"), - new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"), - new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"), - }, - activityItems, - topCategories, - riskFlags, - auditEntries); - - return Results.Ok(response); -}) -.WithName("GetAdminDashboard") -.WithOpenApi(); - -app.MapGet("/api/admin/seasons", async (HttpContext context, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var seasons = await db.Seasons - .AsNoTracking() - .OrderByDescending(item => item.Year) - .Select(item => new AdminSeasonListItemDto( - item.Id, - item.Year, - item.Name, - item.CurrentPhase, - item.IsCurrent, - item.Categories.Count)) - .ToArrayAsync(); - - return Results.Ok(seasons); -}) -.WithName("GetAdminSeasons") -.WithOpenApi(); - -app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var season = await db.Seasons - .AsNoTracking() - .FirstOrDefaultAsync(item => item.Id == seasonId); - - if (season is null) - { - return Results.NotFound(); - } - - var candidates = await db.Candidates - .AsNoTracking() - .Where(item => item.SeasonId == seasonId) - .OrderBy(item => item.DisplayName) - .Select(item => new AdminCandidateItemDto( - item.Id, - item.CategoryId, - item.DisplayName, - item.ChannelSlug, - item.Platform)) - .ToArrayAsync(); - - var candidateCounts = candidates - .GroupBy(item => item.CategoryId) - .ToDictionary(group => group.Key, group => group.Count()); - - var categoryRows = await db.Categories - .AsNoTracking() - .Where(item => item.SeasonId == seasonId) - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Name) - .Select(category => new - { - category.Id, - category.GroupName, - category.Name, - category.Slug, - category.Description, - category.SortOrder, - category.MaxNomineesPerUser, - }) - .ToArrayAsync(); - - var categories = categoryRows - .Select(category => new AdminCategoryItemDto( - category.Id, - category.GroupName, - category.Name, - category.Slug, - category.Description, - category.SortOrder, - category.MaxNomineesPerUser, - candidateCounts.TryGetValue(category.Id, out var count) ? count : 0)) - .ToArray(); - - var pendingNominations = await db.Nominations - .AsNoTracking() - .Where(item => item.SeasonId == seasonId && item.CandidateText != null) - .OrderByDescending(item => item.CreatedAt) - .Take(20) - .Select(item => new AdminNominationReviewItemDto( - item.Id, - item.CategoryId, - item.Category.Name, - item.SubmittedByTwitchId, - item.CandidateText!, - item.CreatedAt)) - .ToArrayAsync(); - - var clipSubmissions = await db.ClipSubmissions - .AsNoTracking() - .Where(item => item.SeasonId == seasonId) - .OrderByDescending(item => item.CreatedAt) - .Take(100) - .Select(item => new AdminClipSubmissionItemDto( - item.Id, - item.CategoryId, - item.SubmittedByTwitchId, - item.ClipUrl, - item.Title, - item.Creator, - item.Platform, - item.Status, - item.CreatedAt)) - .ToArrayAsync(); - - return Results.Ok(new AdminSeasonDetailResponse( - season.Id, - season.Year, - season.Name, - season.CurrentPhase, - season.IsCurrent, - categories, - candidates, - pendingNominations, - clipSubmissions)); -}) -.WithName("GetAdminSeasonDetail") -.WithOpenApi(); - -app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, UpdateSeasonRequest request, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId); - if (season is null) - { - return Results.NotFound(); - } - - season.CurrentPhase = request.CurrentPhase.Trim(); - - if (request.IsCurrent && !season.IsCurrent) - { - var activeSeasons = await db.Seasons.Where(item => item.IsCurrent && item.Id != seasonId).ToListAsync(); - foreach (var activeSeason in activeSeasons) - { - activeSeason.IsCurrent = false; - } - } - - season.IsCurrent = request.IsCurrent; - AddAuditEntry( - db, - session.TwitchUserId, - "season.update", - "season", - season.Id.ToString(), - $"Season {season.Year} wurde aktualisiert.", - new { request.CurrentPhase, request.IsCurrent }); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, seasonId = season.Id }); -}) -.WithName("UpdateAdminSeason") -.WithOpenApi(); - -app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext context, int seasonId, UpsertCategoryRequest request, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId); - if (season is null) - { - return Results.NotFound(); - } - - var category = new Category - { - SeasonId = seasonId, - GroupName = request.GroupName.Trim(), - Name = request.Name.Trim(), - Slug = request.Slug.Trim(), - Description = request.Description.Trim(), - SortOrder = request.SortOrder, - MaxNomineesPerUser = request.MaxNomineesPerUser, - }; - - db.Categories.Add(category); - AddAuditEntry( - db, - session.TwitchUserId, - "category.create", - "category", - request.Slug.Trim(), - $"Kategorie {request.Name.Trim()} wurde angelegt.", - new { seasonId, request.GroupName, request.SortOrder }); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, categoryId = category.Id }); -}) -.WithName("CreateAdminCategory") -.WithOpenApi(); - -app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, UpsertCategoryRequest request, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId); - if (category is null) - { - return Results.NotFound(); - } - - category.GroupName = request.GroupName.Trim(); - category.Name = request.Name.Trim(); - category.Slug = request.Slug.Trim(); - category.Description = request.Description.Trim(); - category.SortOrder = request.SortOrder; - category.MaxNomineesPerUser = request.MaxNomineesPerUser; - - AddAuditEntry( - db, - session.TwitchUserId, - "category.update", - "category", - category.Id.ToString(), - $"Kategorie {request.Name.Trim()} wurde aktualisiert.", - new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser }); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, categoryId = category.Id }); -}) -.WithName("UpdateAdminCategory") -.WithOpenApi(); - -app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext context, int seasonId, UpsertCandidateRequest request, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId); - if (category is null) - { - return Results.BadRequest(new { message = "The selected category does not exist in this season." }); - } - - var candidate = new Candidate - { - SeasonId = seasonId, - CategoryId = request.CategoryId, - DisplayName = request.DisplayName.Trim(), - ChannelSlug = request.ChannelSlug.Trim(), - Platform = request.Platform.Trim(), - }; - - db.Candidates.Add(candidate); - AddAuditEntry( - db, - session.TwitchUserId, - "candidate.create", - "candidate", - request.DisplayName.Trim(), - $"Kandidat {request.DisplayName.Trim()} wurde angelegt.", - new { seasonId, request.CategoryId, request.Platform }); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, candidateId = candidate.Id }); -}) -.WithName("CreateAdminCandidate") -.WithOpenApi(); - -app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, UpsertCandidateRequest request, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId); - if (candidate is null) - { - return Results.NotFound(); - } - - candidate.CategoryId = request.CategoryId; - candidate.DisplayName = request.DisplayName.Trim(); - candidate.ChannelSlug = request.ChannelSlug.Trim(); - candidate.Platform = request.Platform.Trim(); - - AddAuditEntry( - db, - session.TwitchUserId, - "candidate.update", - "candidate", - candidate.Id.ToString(), - $"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.", - new { request.CategoryId, request.Platform }); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, candidateId = candidate.Id }); -}) -.WithName("UpdateAdminCandidate") -.WithOpenApi(); - -app.MapDelete("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId); - if (candidate is null) - { - return Results.NotFound(); - } - - db.Candidates.Remove(candidate); - AddAuditEntry( - db, - session.TwitchUserId, - "candidate.delete", - "candidate", - candidate.Id.ToString(), - $"Kandidat {candidate.DisplayName} wurde gelöscht.", - new { candidate.CategoryId, candidate.Platform }); - await db.SaveChangesAsync(); - - return Results.Ok(new { deleted = true, candidateId }); -}) -.WithName("DeleteAdminCandidate") -.WithOpenApi(); - -app.MapDelete("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId); - if (category is null) - { - return Results.NotFound(); - } - - var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync(); - if (candidates.Length > 0) - { - db.Candidates.RemoveRange(candidates); - } - - db.Categories.Remove(category); - AddAuditEntry( - db, - session.TwitchUserId, - "category.delete", - "category", - category.Id.ToString(), - $"Kategorie {category.Name} wurde gelöscht.", - new { removedCandidates = candidates.Length }); - await db.SaveChangesAsync(); - - return Results.Ok(new { deleted = true, categoryId }); -}) -.WithName("DeleteAdminCategory") -.WithOpenApi(); - -app.MapDelete("/api/admin/clips/{clipId:int}", async (HttpContext context, int clipId, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId); - if (clip is null) - { - return Results.NotFound(); - } - - db.ClipSubmissions.Remove(clip); - AddAuditEntry( - db, - session.TwitchUserId, - "clip.delete", - "clip", - clip.Id.ToString(), - $"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.", - new { clip.Platform }); - await db.SaveChangesAsync(); - - return Results.Ok(new { deleted = true, clipId }); -}) -.WithName("DeleteAdminClip") -.WithOpenApi(); - -app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var nomination = await db.Nominations - .Include(item => item.Category) - .FirstOrDefaultAsync(item => item.Id == nominationId); - - if (nomination is null) - { - return Results.NotFound(); - } - - var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName) - ? nomination.CandidateText - : request.DisplayName.Trim(); - - if (string.IsNullOrWhiteSpace(rawDisplayName)) - { - return Results.BadRequest(new { message = "A display name is required to approve the nomination." }); - } - - var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty; - var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim(); - - var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item => - item.SeasonId == nomination.SeasonId - && item.CategoryId == nomination.CategoryId - && item.DisplayName.ToLower() == rawDisplayName.ToLower()); - - var candidate = existingCandidate; - if (candidate is null) - { - candidate = new Candidate - { - SeasonId = nomination.SeasonId, - CategoryId = nomination.CategoryId, - DisplayName = rawDisplayName, - ChannelSlug = channelSlug, - Platform = platform, - }; - - db.Candidates.Add(candidate); - await db.SaveChangesAsync(); - } - else - { - if (!string.IsNullOrWhiteSpace(channelSlug)) - { - candidate.ChannelSlug = channelSlug; - } - - if (!string.IsNullOrWhiteSpace(platform)) - { - candidate.Platform = platform; - } - } - - nomination.CandidateId = candidate.Id; - nomination.CandidateText = null; - AddAuditEntry( - db, - session.TwitchUserId, - "nomination.approve", - "nomination", - nomination.Id.ToString(), - $"Nominierung {nomination.Id} wurde als Kandidat uebernommen.", - new { candidateId = candidate.Id, created = existingCandidate is null }); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null }); -}) -.WithName("ApproveAdminNomination") -.WithOpenApi(); - -app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpContext context, int nominationId, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId); - if (nomination is null) - { - return Results.NotFound(); - } - - nomination.CandidateText = null; - nomination.CandidateId = null; - AddAuditEntry( - db, - session.TwitchUserId, - "nomination.reject", - "nomination", - nomination.Id.ToString(), - $"Nominierung {nomination.Id} wurde verworfen."); - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true }); -}) -.WithName("RejectAdminNomination") -.WithOpenApi(); - -app.MapPost("/api/admin/risk-flags/{riskFlagId:int}/resolve", async (HttpContext context, int riskFlagId, ResolveRiskFlagRequest request, AwardsDbContext db) => -{ - var session = await ResolveSessionAsync(context, db); - if (session?.Role != "admin") - { - return Results.Unauthorized(); - } - - var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId); - if (riskFlag is null) - { - return Results.NotFound(); - } - - riskFlag.Status = string.IsNullOrWhiteSpace(request.Status) ? "resolved" : request.Status.Trim().ToLowerInvariant(); - riskFlag.ReviewedAt = DateTimeOffset.UtcNow; - riskFlag.ReviewedByTwitchId = session.TwitchUserId; - - AddAuditEntry( - db, - session.TwitchUserId, - "risk.resolve", - "risk-flag", - riskFlag.Id.ToString(), - $"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.", - new { riskFlag.Type, riskFlag.Source }); - - await db.SaveChangesAsync(); - - return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status }); -}) -.WithName("ResolveRiskFlag") -.WithOpenApi(); +app.UseApplicationPipeline(); +await app.InitializeDatabaseAsync(); +app.MapApplicationEndpoints(); app.Run(); diff --git a/Backend/README.md b/Backend/README.md index 47b67d2..816b973 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -4,16 +4,33 @@ The API targets PostgreSQL through EF Core 8 and `Npgsql.EntityFrameworkCore.PostgreSQL`. -Default local development connection string: +Local development is aligned with `../docker-compose.dev.yml` and `appsettings.Development.json`: ```text -Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres +Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only ``` -You can override it with: +For production or shared deployments, keep checked-in appsettings empty and provide your own value through: + +```text +VTSA_POSTGRES +ConnectionStrings__Postgres +``` + +The API reads its connection string from: -- `Backend/appsettings.Development.json` - environment variable `VTSA_POSTGRES` +- environment variable `ConnectionStrings__Postgres` + +Presentation/demo data is controlled separately: + +```text +VTSA_SEED_MODE=demo +``` + +- `demo`, `presentation` or `sample`: seed local presentation data. +- `none`, `off`, `disabled` or an unset value in Production: do not seed presentation data. +- `Backend/appsettings.Development.json` defaults to `demo`; `Backend/appsettings.json` defaults to `none`. If Docker is available locally, start a dev database from the repository root with: @@ -51,20 +68,20 @@ dotnet ef database update Fallback bootstrap if `dotnet ef` is not usable in the current environment: ```bash -psql "Host=localhost Port=5433 Database=vtuber_star_awards_dev Username=postgres Password=postgres" -f Migrations/InitialCreate.manual.sql +psql "$VTSA_POSTGRES" -f Migrations/InitialCreate.manual.sql ``` Run the API: ```bash -ASPNETCORE_ENVIRONMENT=Development dotnet run +ASPNETCORE_ENVIRONMENT=Development dotnet run --urls http://127.0.0.1:5084 ``` Check the API and database wiring: ```bash -curl http://localhost:5084/api/health -curl http://localhost:5084/api/health/database +curl http://127.0.0.1:5084/api/health +curl http://127.0.0.1:5084/api/health/database ``` Development auth/session: @@ -72,5 +89,27 @@ Development auth/session: ```bash curl -X POST http://localhost:5084/api/auth/dev-login \ -H "Content-Type: application/json" \ - -d '{"twitchUserId":"admin_demo","displayName":"Admin Demo","role":"admin"}' + -d '{"twitchUserId":"jayuhime_admin","displayName":"Jayuhime Admin","role":"admin"}' ``` + +Demo admin login for public presentations: + +```text +VTSA_DEMO_LOGIN_ENABLED=true +VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin +VTSA_DEMO_ADMIN_EMAIL=admin@example.local +VTSA_DEMO_ADMIN_PASSWORD= +VTSA_DEMO_ADMIN_TWITCH_ID=jayuhime_admin +VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin +``` + +The frontend route is `/login`. `VTSA_DEMO_ADMIN_LOGIN` may be a username or an email-style identifier; the backend also accepts the configured email, Twitch ID, and display name for admin flexibility. Disable the demo login for release with `VTSA_DEMO_LOGIN_ENABLED=false`. + +Frontend app-wide demo gate: + +```text +VITE_DEMO_GATE_ENABLED=true +``` + +- `true`: the whole Vue app starts at `/login` until a session exists. +- unset or `false`: public pages such as `/` are visible without the initial demo login. diff --git a/Backend/Repositories/AdminAuditRepository.cs b/Backend/Repositories/AdminAuditRepository.cs new file mode 100644 index 0000000..1d764b6 --- /dev/null +++ b/Backend/Repositories/AdminAuditRepository.cs @@ -0,0 +1,9 @@ +using Backend.Data; +using Backend.Domain; + +namespace Backend.Repositories; + +public sealed class AdminAuditRepository(AwardsDbContext dbContext) : IAdminAuditRepository +{ + public void Add(AdminAuditEntry entry) => dbContext.AdminAuditEntries.Add(entry); +} diff --git a/Backend/Repositories/IAdminAuditRepository.cs b/Backend/Repositories/IAdminAuditRepository.cs new file mode 100644 index 0000000..a4e8d0d --- /dev/null +++ b/Backend/Repositories/IAdminAuditRepository.cs @@ -0,0 +1,8 @@ +using Backend.Domain; + +namespace Backend.Repositories; + +public interface IAdminAuditRepository +{ + void Add(AdminAuditEntry entry); +} diff --git a/Backend/Repositories/IRiskFlagRepository.cs b/Backend/Repositories/IRiskFlagRepository.cs new file mode 100644 index 0000000..7558620 --- /dev/null +++ b/Backend/Repositories/IRiskFlagRepository.cs @@ -0,0 +1,17 @@ +using Backend.Domain; + +namespace Backend.Repositories; + +public interface IRiskFlagRepository +{ + Task ExistsOpenRecentAsync( + int? seasonId, + string? twitchUserId, + string source, + string type, + string clientIp, + DateTimeOffset threshold, + CancellationToken cancellationToken = default); + + void Add(RiskFlag riskFlag); +} diff --git a/Backend/Repositories/IUserSessionRepository.cs b/Backend/Repositories/IUserSessionRepository.cs new file mode 100644 index 0000000..9b81704 --- /dev/null +++ b/Backend/Repositories/IUserSessionRepository.cs @@ -0,0 +1,11 @@ +using Backend.Domain; + +namespace Backend.Repositories; + +public interface IUserSessionRepository +{ + Task GetActiveByTokenAsync(string token, CancellationToken cancellationToken = default); + Task CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default); + void Add(UserSession session); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/Backend/Repositories/RiskFlagRepository.cs b/Backend/Repositories/RiskFlagRepository.cs new file mode 100644 index 0000000..78eb034 --- /dev/null +++ b/Backend/Repositories/RiskFlagRepository.cs @@ -0,0 +1,28 @@ +using Backend.Data; +using Backend.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Repositories; + +public sealed class RiskFlagRepository(AwardsDbContext dbContext) : IRiskFlagRepository +{ + public Task ExistsOpenRecentAsync( + int? seasonId, + string? twitchUserId, + string source, + string type, + string clientIp, + DateTimeOffset threshold, + CancellationToken cancellationToken = default) => + dbContext.RiskFlags.AnyAsync( + item => item.Status == "open" + && item.Source == source + && item.Type == type + && item.TwitchUserId == twitchUserId + && item.CreatedFromIp == clientIp + && item.SeasonId == seasonId + && item.CreatedAt >= threshold, + cancellationToken); + + public void Add(RiskFlag riskFlag) => dbContext.RiskFlags.Add(riskFlag); +} diff --git a/Backend/Repositories/UserSessionRepository.cs b/Backend/Repositories/UserSessionRepository.cs new file mode 100644 index 0000000..00c8946 --- /dev/null +++ b/Backend/Repositories/UserSessionRepository.cs @@ -0,0 +1,23 @@ +using Backend.Data; +using Backend.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Repositories; + +public sealed class UserSessionRepository(AwardsDbContext dbContext) : IUserSessionRepository +{ + public Task GetActiveByTokenAsync(string token, CancellationToken cancellationToken = default) => + dbContext.UserSessions.FirstOrDefaultAsync( + item => item.SessionToken == token && item.IsActive, + cancellationToken); + + public Task CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default) => + dbContext.UserSessions.CountAsync( + item => item.CreatedFromIp == ipAddress && item.CreatedAt >= since, + cancellationToken); + + public void Add(UserSession session) => dbContext.UserSessions.Add(session); + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => + dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/Backend/Security/AdminRoles.cs b/Backend/Security/AdminRoles.cs new file mode 100644 index 0000000..8091b01 --- /dev/null +++ b/Backend/Security/AdminRoles.cs @@ -0,0 +1,40 @@ +namespace Backend.Security; + +public static class AdminRoles +{ + public const string Viewer = "viewer"; + public const string ContentAdmin = "content_admin"; + public const string Admin = "admin"; + public const string Owner = "owner"; + + public static string Normalize(string? role) + { + var normalizedRole = (role ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_'); + + return normalizedRole switch + { + Owner => Owner, + Admin => Admin, + ContentAdmin => ContentAdmin, + _ => Viewer, + }; + } + + public static bool IsKnownRole(string? role) + { + var normalizedRole = (role ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_'); + return normalizedRole is Viewer or ContentAdmin or Admin or Owner; + } + + public static bool CanAccessAdmin(string? role) => + Normalize(role) is ContentAdmin or Admin or Owner; + + public static bool CanManageContent(string? role) => + Normalize(role) is ContentAdmin or Admin or Owner; + + public static bool CanManageAdminWorkspace(string? role) => + Normalize(role) is Admin or Owner; + + public static bool CanManageOperationalSettings(string? role) => + Normalize(role) is Owner; +} diff --git a/Backend/Security/AdminSessionFilter.cs b/Backend/Security/AdminSessionFilter.cs new file mode 100644 index 0000000..f1e154f --- /dev/null +++ b/Backend/Security/AdminSessionFilter.cs @@ -0,0 +1,23 @@ +using Backend.Services; + +namespace Backend.Security; + +public sealed class AdminSessionFilter(IUserSessionService userSessionService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var session = await userSessionService.ResolveSessionAsync(context.HttpContext, context.HttpContext.RequestAborted); + if (session is null) + { + return Results.Unauthorized(); + } + + if (!AdminRoles.CanAccessAdmin(session.Role)) + { + return Results.Json(new { message = "Admin access requires an elevated role." }, statusCode: StatusCodes.Status403Forbidden); + } + + context.HttpContext.SetCurrentSession(session); + return await next(context); + } +} diff --git a/Backend/Security/DemoCredentialHasher.cs b/Backend/Security/DemoCredentialHasher.cs new file mode 100644 index 0000000..056569a --- /dev/null +++ b/Backend/Security/DemoCredentialHasher.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Backend.Security; + +public static class DemoCredentialHasher +{ + private const int SaltSize = 16; + private const int HashSize = 32; + private const int Iterations = 100_000; + + public static (string Hash, string Salt) HashPassword(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + Iterations, + HashAlgorithmName.SHA256, + HashSize); + + return (Convert.ToBase64String(hash), Convert.ToBase64String(salt)); + } + + public static bool VerifyPassword(string password, string expectedHash, string salt) + { + if (string.IsNullOrWhiteSpace(expectedHash) || string.IsNullOrWhiteSpace(salt)) + { + return false; + } + + try + { + var saltBytes = Convert.FromBase64String(salt); + var expectedBytes = Convert.FromBase64String(expectedHash); + var candidateBytes = Rfc2898DeriveBytes.Pbkdf2( + password, + saltBytes, + Iterations, + HashAlgorithmName.SHA256, + expectedBytes.Length); + + return CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes); + } + catch (FormatException) + { + return false; + } + } + + public static bool FixedTimePlainTextEquals(string candidate, string expected) + { + var candidateBytes = Encoding.UTF8.GetBytes(candidate); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + + return candidateBytes.Length == expectedBytes.Length + && CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes); + } +} diff --git a/Backend/Security/HttpContextSessionExtensions.cs b/Backend/Security/HttpContextSessionExtensions.cs new file mode 100644 index 0000000..62b7e61 --- /dev/null +++ b/Backend/Security/HttpContextSessionExtensions.cs @@ -0,0 +1,14 @@ +using Backend.Domain; + +namespace Backend.Security; + +public static class HttpContextSessionExtensions +{ + private const string SessionItemKey = "__current_session"; + + public static void SetCurrentSession(this HttpContext context, UserSession session) => + context.Items[SessionItemKey] = session; + + public static UserSession? GetCurrentSession(this HttpContext context) => + context.Items.TryGetValue(SessionItemKey, out var value) ? value as UserSession : null; +} diff --git a/Backend/Security/SecurityHeadersMiddleware.cs b/Backend/Security/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..741e90b --- /dev/null +++ b/Backend/Security/SecurityHeadersMiddleware.cs @@ -0,0 +1,35 @@ +namespace Backend.Security; + +public sealed class SecurityHeadersMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + context.Response.OnStarting(() => + { + var headers = context.Response.Headers; + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"; + headers["Cross-Origin-Opener-Policy"] = "same-origin"; + + if (!headers.ContainsKey("Content-Security-Policy")) + { + headers["Content-Security-Policy"] = + "default-src 'self'; " + + "img-src 'self' data: https:; " + + "font-src 'self' https://fonts.gstatic.com data:; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "script-src 'self'; " + + "connect-src 'self' https:; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self';"; + } + + return Task.CompletedTask; + }); + + await next(context); + } +} diff --git a/Backend/Services/AdminAuditService.cs b/Backend/Services/AdminAuditService.cs new file mode 100644 index 0000000..8c14f85 --- /dev/null +++ b/Backend/Services/AdminAuditService.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using Backend.Common; +using Backend.Domain; +using Backend.Repositories; + +namespace Backend.Services; + +public sealed class AdminAuditService(IAdminAuditRepository adminAuditRepository) : IAdminAuditService +{ + public void AddEntry( + string adminTwitchUserId, + string actionType, + string entityType, + string entityId, + string summary, + object? metadata = null, + RequestMetadata? requestMetadata = null) + { + adminAuditRepository.Add(new AdminAuditEntry + { + AdminTwitchUserId = adminTwitchUserId, + ActionType = actionType, + EntityType = entityType, + EntityId = entityId, + Summary = summary, + MetadataJson = JsonSerializer.Serialize(metadata ?? new { }), + CreatedFromIp = requestMetadata?.ClientIp ?? string.Empty, + UserAgent = requestMetadata?.UserAgent ?? string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }); + } +} diff --git a/Backend/Services/IAdminAuditService.cs b/Backend/Services/IAdminAuditService.cs new file mode 100644 index 0000000..3d4df94 --- /dev/null +++ b/Backend/Services/IAdminAuditService.cs @@ -0,0 +1,15 @@ +using Backend.Common; + +namespace Backend.Services; + +public interface IAdminAuditService +{ + void AddEntry( + string adminTwitchUserId, + string actionType, + string entityType, + string entityId, + string summary, + object? metadata = null, + RequestMetadata? requestMetadata = null); +} diff --git a/Backend/Services/IRiskFlagService.cs b/Backend/Services/IRiskFlagService.cs new file mode 100644 index 0000000..3ef2377 --- /dev/null +++ b/Backend/Services/IRiskFlagService.cs @@ -0,0 +1,17 @@ +using Backend.Common; + +namespace Backend.Services; + +public interface IRiskFlagService +{ + Task AddIfMissingAsync( + int? seasonId, + string? twitchUserId, + string source, + string type, + string severity, + string summary, + RequestMetadata requestMetadata, + object? metadata = null, + CancellationToken cancellationToken = default); +} diff --git a/Backend/Services/IRiskRuleService.cs b/Backend/Services/IRiskRuleService.cs new file mode 100644 index 0000000..e48e594 --- /dev/null +++ b/Backend/Services/IRiskRuleService.cs @@ -0,0 +1,7 @@ +namespace Backend.Services; + +public interface IRiskRuleService +{ + Task GetRulesAsync(CancellationToken cancellationToken = default); + Task GetRuleAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/Backend/Services/IUserSessionService.cs b/Backend/Services/IUserSessionService.cs new file mode 100644 index 0000000..8019965 --- /dev/null +++ b/Backend/Services/IUserSessionService.cs @@ -0,0 +1,14 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Domain; + +namespace Backend.Services; + +public interface IUserSessionService +{ + Task ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default); + Task CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default); + Task CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default); + Task CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default); + Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default); +} diff --git a/Backend/Services/RiskFlagService.cs b/Backend/Services/RiskFlagService.cs new file mode 100644 index 0000000..8bc838e --- /dev/null +++ b/Backend/Services/RiskFlagService.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using Backend.Common; +using Backend.Domain; +using Backend.Repositories; + +namespace Backend.Services; + +public sealed class RiskFlagService( + IRiskFlagRepository riskFlagRepository, + IRiskRuleService riskRuleService) : IRiskFlagService +{ + public async Task AddIfMissingAsync( + int? seasonId, + string? twitchUserId, + string source, + string type, + string severity, + string summary, + RequestMetadata requestMetadata, + object? metadata = null, + CancellationToken cancellationToken = default) + { + var rule = await riskRuleService.GetRuleAsync(type, cancellationToken); + if (!rule.Enabled) + { + return; + } + + var threshold = DateTimeOffset.UtcNow.AddMinutes(-rule.WindowMinutes); + var exists = await riskFlagRepository.ExistsOpenRecentAsync( + seasonId, + twitchUserId, + source, + type, + requestMetadata.ClientIp, + threshold, + cancellationToken); + + if (exists) + { + return; + } + + riskFlagRepository.Add(new RiskFlag + { + SeasonId = seasonId, + TwitchUserId = twitchUserId, + Source = source, + Type = type, + Severity = severity, + Status = "open", + Summary = summary, + CreatedFromIp = requestMetadata.ClientIp, + UserAgent = requestMetadata.UserAgent, + MetadataJson = JsonSerializer.Serialize(metadata ?? new { }), + CreatedAt = DateTimeOffset.UtcNow, + }); + } +} diff --git a/Backend/Services/RiskRuleService.cs b/Backend/Services/RiskRuleService.cs new file mode 100644 index 0000000..0aba09b --- /dev/null +++ b/Backend/Services/RiskRuleService.cs @@ -0,0 +1,19 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Services; + +public sealed class RiskRuleService(AwardsDbContext db) : IRiskRuleService +{ + public async Task GetRulesAsync(CancellationToken cancellationToken = default) + { + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); + + return RiskRuleSettings.Read(settings); + } + + public async Task GetRuleAsync(string key, CancellationToken cancellationToken = default) => + RiskRuleSettings.Find(await GetRulesAsync(cancellationToken), key); +} diff --git a/Backend/Services/RiskRuleSettings.cs b/Backend/Services/RiskRuleSettings.cs new file mode 100644 index 0000000..b60f395 --- /dev/null +++ b/Backend/Services/RiskRuleSettings.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Backend.Domain; + +namespace Backend.Services; + +public sealed record RiskRuleSetting( + string Key, + string Label, + bool Enabled, + int Threshold, + int WindowMinutes, + string Severity, + string Description); + +public static class RiskRuleSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static RiskRuleSetting[] Defaults { get; } = + [ + new("resubmitted_ballot", "Ballot erneut gespeichert", true, 1, 360, "low", "Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert."), + new("rapid_vote_updates", "Voting-Burst", true, 3, 10, "high", "Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest."), + new("resubmitted_nomination", "Nominierung erneut eingereicht", true, 1, 360, "low", "Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert."), + new("rapid_nomination_burst", "Nominierungs-Burst", true, 10, 10, "high", "Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet."), + new("duplicate_clip_submission", "Doppelter Clip", true, 1, 360, "medium", "Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht."), + new("rapid_clip_burst", "Clip-Burst", true, 5, 10, "high", "Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet."), + new("rapid_login_ip", "Login-Burst pro IP", true, 3, 15, "medium", "Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen."), + new("rapid_demo_login_ip", "Demo-Login-Burst pro IP", true, 3, 15, "medium", "Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen."), + ]; + + public static RiskRuleSetting[] Read(SiteSettings? settings) + { + var storedRules = Parse(settings?.RiskRulesJson); + 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 RiskRuleSetting Find(IEnumerable rules, string key) => + rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)) + ?? Defaults.First(item => item.Key == key); + + private static RiskRuleSetting[] Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static RiskRuleSetting Normalize(RiskRuleSetting rule, RiskRuleSetting fallback) + { + var severity = rule.Severity.Trim().ToLowerInvariant() switch + { + "high" => "high", + "medium" => "medium", + "low" => "low", + _ => fallback.Severity, + }; + + return rule with + { + Key = fallback.Key, + Label = string.IsNullOrWhiteSpace(rule.Label) ? fallback.Label : rule.Label.Trim(), + Threshold = Math.Clamp(rule.Threshold, 1, 500), + WindowMinutes = Math.Clamp(rule.WindowMinutes, 1, 1440), + Severity = severity, + Description = string.IsNullOrWhiteSpace(rule.Description) ? fallback.Description : rule.Description.Trim(), + }; + } +} diff --git a/Backend/Services/UserSessionService.cs b/Backend/Services/UserSessionService.cs new file mode 100644 index 0000000..28ea062 --- /dev/null +++ b/Backend/Services/UserSessionService.cs @@ -0,0 +1,91 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Domain; +using Backend.Repositories; +using Backend.Security; +using System.Security.Cryptography; + +namespace Backend.Services; + +public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService +{ + public async Task ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default) + { + var token = ReadBearerToken(context); + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + var session = await userSessionRepository.GetActiveByTokenAsync(token, cancellationToken); + if (session is null) + { + return null; + } + + session.LastSeenAt = DateTimeOffset.UtcNow; + await userSessionRepository.SaveChangesAsync(cancellationToken); + context.SetCurrentSession(session); + return session; + } + + public Task CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default) + { + return CreateSessionAsync( + request.TwitchUserId, + request.DisplayName, + request.Role, + metadata, + cancellationToken); + } + + public Task CreateSessionAsync( + string twitchUserId, + string displayName, + string role, + RequestMetadata metadata, + CancellationToken cancellationToken = default) + { + var normalizedTwitchUserId = twitchUserId.Trim(); + var normalizedDisplayName = displayName.Trim(); + var session = new UserSession + { + Id = Guid.NewGuid(), + SessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(), + TwitchUserId = normalizedTwitchUserId, + DisplayName = normalizedDisplayName, + Role = AdminRoles.Normalize(role), + CreatedFromIp = metadata.ClientIp, + UserAgent = metadata.UserAgent, + CreatedAt = DateTimeOffset.UtcNow, + LastSeenAt = DateTimeOffset.UtcNow, + IsActive = true, + }; + + userSessionRepository.Add(session); + return PersistAndReturnAsync(session, cancellationToken); + } + + public Task CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default) => + userSessionRepository.CountRecentSessionsFromIpAsync(ipAddress, since, cancellationToken); + + public async Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default) + { + session.IsActive = false; + await userSessionRepository.SaveChangesAsync(cancellationToken); + } + + private static string? ReadBearerToken(HttpContext context) + { + var header = context.Request.Headers.Authorization.ToString(); + return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? header["Bearer ".Length..].Trim() + : null; + } + + private async Task PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken) + { + await userSessionRepository.SaveChangesAsync(cancellationToken); + return session; + } +} diff --git a/Backend/appsettings.Development.json b/Backend/appsettings.Development.json index f3abc46..7b9a9af 100644 --- a/Backend/appsettings.Development.json +++ b/Backend/appsettings.Development.json @@ -1,6 +1,25 @@ { + "Frontend": { + "AllowedOrigins": [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173" + ] + }, "ConnectionStrings": { - "Postgres": "Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres" + "Postgres": "Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only" + }, + "SeedData": { + "Mode": "demo" + }, + "DemoAdmin": { + "Enabled": true, + "Login": "jayuhime_admin", + "Email": "admin@example.local", + "Password": "change-me-for-demo", + "TwitchUserId": "jayuhime_admin", + "DisplayName": "Jayuhime Admin" }, "Logging": { "LogLevel": { diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 08ea3fe..b80abb5 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -1,6 +1,20 @@ { + "Frontend": { + "AllowedOrigins": [] + }, "ConnectionStrings": { - "Postgres": "Host=localhost;Port=5432;Database=vtuber_star_awards;Username=postgres;Password=postgres" + "Postgres": "" + }, + "SeedData": { + "Mode": "none" + }, + "DemoAdmin": { + "Enabled": false, + "Login": "", + "Email": "", + "Password": "", + "TwitchUserId": "jayuhime_admin", + "DisplayName": "Jayuhime Admin" }, "Logging": { "LogLevel": { diff --git a/README.md b/README.md index d6f42f4..4dc5675 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,19 @@ The frontend uses a lightweight local session flow for development: ## Backend -Update `Backend/appsettings.json` or set `VTSA_POSTGRES`, then: +Development uses the same local PostgreSQL defaults as `docker-compose.dev.yml`: + +```text +Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only +``` + +Those defaults are already present in `Backend/appsettings.Development.json`. For other environments, keep `Backend/appsettings.json` empty and set `VTSA_POSTGRES` or `ConnectionStrings__Postgres`. ```bash cd Backend dotnet restore dotnet build -dotnet run +ASPNETCORE_ENVIRONMENT=Development dotnet run --urls http://127.0.0.1:5084 ``` ## Local Database @@ -54,21 +60,23 @@ dotnet ef database update Default dev database: ```text -Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres +Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only ``` +Override `VTSA_DEV_POSTGRES_USER` and `VTSA_DEV_POSTGRES_PASSWORD` before starting Docker if you want different local credentials. + If `dotnet ef database update` is unavailable in the current environment, the repository also contains a bootstrap SQL script: ```bash cd Backend -psql "Host=localhost Port=5433 Database=vtuber_star_awards_dev Username=postgres Password=postgres" -f Migrations/InitialCreate.manual.sql +psql "Host=localhost Port=5433 Database=vtuber_star_awards_dev Username=vtsa_dev Password=change-me-local-only" -f Migrations/InitialCreate.manual.sql ``` Verify the runtime wiring: ```bash -curl http://localhost:5084/api/health -curl http://localhost:5084/api/health/database +curl http://127.0.0.1:5084/api/health +curl http://127.0.0.1:5084/api/health/database ``` Development auth/session endpoints: @@ -87,3 +95,18 @@ curl -X POST http://localhost:5084/api/auth/dev-login \ - Database connectivity and pending migrations are exposed at `/api/health/database` - Current frontend store falls back to static seed-like data if the API is unavailable - The admin dashboard now includes a lightweight risk center and audit log for suspicious submit patterns and reviewed admin actions + +## Suggested Backend Rollout Phases + +1. Public participation core + - Home, nominations, voting, clip submission, winner archive + - Backend focus: overview/category/archive reads plus persisted user participation state +2. Admin season setup + - Admin years, categories, candidates, season status + - Backend focus: season/category/candidate CRUD and current-year switching +3. Review and moderation + - Admin reviews, nominations inbox, clips, risk center + - Backend focus: nomination approval/rejection, clip moderation states, risk resolution workflows +4. Reporting and operations + - Admin dashboard, analytics, user logs, settings health checks + - Backend focus: richer aggregates, audit search/filtering, operational health endpoints diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a60055a..da55b38 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,9 +4,9 @@ services: container_name: vtubeawards-postgres restart: unless-stopped environment: - POSTGRES_DB: vtuber_star_awards_dev - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_DB: ${VTSA_DEV_POSTGRES_DB:-vtuber_star_awards_dev} + POSTGRES_USER: ${VTSA_DEV_POSTGRES_USER:-vtsa_dev} + POSTGRES_PASSWORD: ${VTSA_DEV_POSTGRES_PASSWORD:-change-me-local-only} ports: - "5433:5432" volumes: diff --git a/frontend/.env.example b/frontend/.env.example index aa227dd..0f4e8cc 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1,2 @@ VITE_API_URL=http://127.0.0.1:5084 +VITE_DEMO_GATE_ENABLED=true diff --git a/frontend/index.html b/frontend/index.html index 096d706..4490a8d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,11 @@ - + - frontend + + VTuber Star Awards
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11ada5b..a2266f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "primeicons": "^7.0.0", "primevue": "^4.5.5", "shadcn-vue": "^2.7.4", + "simple-icons": "^16.24.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", "vue": "^3.5.34", @@ -6670,6 +6671,25 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-icons": { + "version": "16.24.0", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.24.0.tgz", + "integrity": "sha512-lAPW1rqgwPQ4tdIY15TtcKSgSelvJexz8q/B+a7Igg1dJoXR0LPjScLkLMI8UbLSkS41/fLVZWIhsH7HPUwAgQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + }, + { + "type": "github", + "url": "https://github.com/sponsors/simple-icons" + } + ], + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index f890e60..c46cb59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "primeicons": "^7.0.0", "primevue": "^4.5.5", "shadcn-vue": "^2.7.4", + "simple-icons": "^16.24.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", "vue": "^3.5.34", diff --git a/frontend/public/assets/jayu-hero.png b/frontend/public/assets/jayu-hero.png new file mode 100644 index 0000000..562bf43 Binary files /dev/null and b/frontend/public/assets/jayu-hero.png differ diff --git a/frontend/public/assets/jayu-host.png b/frontend/public/assets/jayu-host.png new file mode 100644 index 0000000..216e060 Binary files /dev/null and b/frontend/public/assets/jayu-host.png differ diff --git a/frontend/public/assets/jayu-trophy.png b/frontend/public/assets/jayu-trophy.png new file mode 100644 index 0000000..3f290f3 Binary files /dev/null and b/frontend/public/assets/jayu-trophy.png differ diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index c66ccc9..1c2c93c 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -1,40 +1,46 @@ + + diff --git a/frontend/src/components/CinematicStarLoader.vue b/frontend/src/components/CinematicStarLoader.vue new file mode 100644 index 0000000..c6451ec --- /dev/null +++ b/frontend/src/components/CinematicStarLoader.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/components/admin/AdminAuditDetailDrawer.vue b/frontend/src/components/admin/AdminAuditDetailDrawer.vue new file mode 100644 index 0000000..d56527e --- /dev/null +++ b/frontend/src/components/admin/AdminAuditDetailDrawer.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/src/components/admin/AdminAuditFocusPanel.vue b/frontend/src/components/admin/AdminAuditFocusPanel.vue new file mode 100644 index 0000000..414d8e5 --- /dev/null +++ b/frontend/src/components/admin/AdminAuditFocusPanel.vue @@ -0,0 +1,126 @@ + + + diff --git a/frontend/src/components/admin/AdminAuditLogList.vue b/frontend/src/components/admin/AdminAuditLogList.vue new file mode 100644 index 0000000..f4e4a8d --- /dev/null +++ b/frontend/src/components/admin/AdminAuditLogList.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/components/admin/AdminAuditOverviewBar.vue b/frontend/src/components/admin/AdminAuditOverviewBar.vue new file mode 100644 index 0000000..6ec30fa --- /dev/null +++ b/frontend/src/components/admin/AdminAuditOverviewBar.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/components/admin/AdminAwardYearsPanel.vue b/frontend/src/components/admin/AdminAwardYearsPanel.vue new file mode 100644 index 0000000..0730e58 --- /dev/null +++ b/frontend/src/components/admin/AdminAwardYearsPanel.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/components/admin/AdminCandidateDeleteModal.vue b/frontend/src/components/admin/AdminCandidateDeleteModal.vue new file mode 100644 index 0000000..ee2c992 --- /dev/null +++ b/frontend/src/components/admin/AdminCandidateDeleteModal.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/admin/AdminCandidateEditorModal.vue b/frontend/src/components/admin/AdminCandidateEditorModal.vue new file mode 100644 index 0000000..ea8f10a --- /dev/null +++ b/frontend/src/components/admin/AdminCandidateEditorModal.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/components/admin/AdminCandidatesFiltersBar.vue b/frontend/src/components/admin/AdminCandidatesFiltersBar.vue new file mode 100644 index 0000000..74d48c6 --- /dev/null +++ b/frontend/src/components/admin/AdminCandidatesFiltersBar.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/components/admin/AdminCandidatesTable.vue b/frontend/src/components/admin/AdminCandidatesTable.vue new file mode 100644 index 0000000..12436a2 --- /dev/null +++ b/frontend/src/components/admin/AdminCandidatesTable.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/components/admin/AdminContentBasicsSection.vue b/frontend/src/components/admin/AdminContentBasicsSection.vue new file mode 100644 index 0000000..a30802f --- /dev/null +++ b/frontend/src/components/admin/AdminContentBasicsSection.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/admin/AdminContentFaqSection.vue b/frontend/src/components/admin/AdminContentFaqSection.vue new file mode 100644 index 0000000..158f279 --- /dev/null +++ b/frontend/src/components/admin/AdminContentFaqSection.vue @@ -0,0 +1,53 @@ + diff --git a/frontend/src/components/admin/AdminPageHeader.vue b/frontend/src/components/admin/AdminPageHeader.vue index 04eca24..880da19 100644 --- a/frontend/src/components/admin/AdminPageHeader.vue +++ b/frontend/src/components/admin/AdminPageHeader.vue @@ -1,50 +1,38 @@ diff --git a/frontend/src/components/admin/AdminReviewDecisionPanel.vue b/frontend/src/components/admin/AdminReviewDecisionPanel.vue new file mode 100644 index 0000000..9407a93 --- /dev/null +++ b/frontend/src/components/admin/AdminReviewDecisionPanel.vue @@ -0,0 +1,158 @@ + + + + + + + + + + + + diff --git a/frontend/src/components/home/HomeLandingExperience.vue b/frontend/src/components/home/HomeLandingExperience.vue new file mode 100644 index 0000000..6745d3e --- /dev/null +++ b/frontend/src/components/home/HomeLandingExperience.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/frontend/src/components/home/HomeLandingModals.vue b/frontend/src/components/home/HomeLandingModals.vue new file mode 100644 index 0000000..b3cd4ff --- /dev/null +++ b/frontend/src/components/home/HomeLandingModals.vue @@ -0,0 +1,153 @@ + + + diff --git a/frontend/src/components/home/HomeParticipationSection.vue b/frontend/src/components/home/HomeParticipationSection.vue new file mode 100644 index 0000000..74d77e9 --- /dev/null +++ b/frontend/src/components/home/HomeParticipationSection.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/components/home/HomeSelectDropdown.vue b/frontend/src/components/home/HomeSelectDropdown.vue new file mode 100644 index 0000000..ac608e8 --- /dev/null +++ b/frontend/src/components/home/HomeSelectDropdown.vue @@ -0,0 +1,331 @@ + + + + + diff --git a/frontend/src/components/home/HomeSupportFooterSection.vue b/frontend/src/components/home/HomeSupportFooterSection.vue new file mode 100644 index 0000000..117a88e --- /dev/null +++ b/frontend/src/components/home/HomeSupportFooterSection.vue @@ -0,0 +1,120 @@ + + + diff --git a/frontend/src/components/home/HomeTimelineSection.vue b/frontend/src/components/home/HomeTimelineSection.vue new file mode 100644 index 0000000..0c73c56 --- /dev/null +++ b/frontend/src/components/home/HomeTimelineSection.vue @@ -0,0 +1,182 @@ + + + diff --git a/frontend/src/components/home/HomeTopNav.vue b/frontend/src/components/home/HomeTopNav.vue new file mode 100644 index 0000000..df5af54 --- /dev/null +++ b/frontend/src/components/home/HomeTopNav.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/home/HomeVotingPickerPane.vue b/frontend/src/components/home/HomeVotingPickerPane.vue new file mode 100644 index 0000000..e932007 --- /dev/null +++ b/frontend/src/components/home/HomeVotingPickerPane.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/src/components/home/HomeWinnerShowcaseSection.vue b/frontend/src/components/home/HomeWinnerShowcaseSection.vue new file mode 100644 index 0000000..7cf0b8c --- /dev/null +++ b/frontend/src/components/home/HomeWinnerShowcaseSection.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/home/homeLandingExperience.css b/frontend/src/components/home/homeLandingExperience.css new file mode 100644 index 0000000..d6de400 --- /dev/null +++ b/frontend/src/components/home/homeLandingExperience.css @@ -0,0 +1,898 @@ +@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Fredoka:wght@400;500;600;700&family=Great+Vibes&family=Outfit:wght@300;400;500;600;700&family=Sacramento&display=swap"); + +*{box-sizing:border-box;} +html,body{margin:0;padding:0;background:#160a26;} +@keyframes twinkle{0%,100%{opacity:.25;transform:scale(.7);}50%{opacity:1;transform:scale(1.15);}} +@keyframes floaty{0%,100%{transform:translateY(0);}50%{transform:translateY(-16px);}} +@keyframes floaty2{0%,100%{transform:translateY(0) rotate(-4deg);}50%{transform:translateY(-22px) rotate(4deg);}} +@keyframes pulseGlow{0%,100%{opacity:.55;}50%{opacity:1;}} +@keyframes spinSlow{from{transform:rotate(0);}to{transform:rotate(360deg);}} +@keyframes shimmer{0%{background-position:0% 50%;}100%{background-position:200% 50%;}} +#faq summary::-webkit-details-marker{display:none;} +#faq details[open] summary span{transform:rotate(45deg);} + +.home-landing{ + overflow-x:clip; +} + +.home-demo-preview{ + position:fixed; + left:50%; + bottom:22px; + z-index:95; + display:flex; + align-items:center; + gap:12px; + max-width:calc(100vw - 28px); + padding:10px 12px; + border:1px solid rgba(139,108,219,.22); + border-radius:999px; + background:rgba(255,255,255,.78); + box-shadow:0 18px 52px rgba(63,53,86,.18); + backdrop-filter:blur(18px); + -webkit-backdrop-filter:blur(18px); + transform:translateX(-50%); +} + +.home-demo-preview__label{ + display:inline-flex; + align-items:center; + gap:7px; + padding:0 7px 0 4px; + color:#5f44ad; + font-family:'Fredoka',sans-serif; + font-size:13px; + font-weight:800; + letter-spacing:.12em; + text-transform:uppercase; + white-space:nowrap; +} + +.home-demo-preview__label span{ + display:inline-grid; + place-items:center; + width:28px; + height:28px; + border-radius:50%; + color:#fff; + background:linear-gradient(135deg,#8b6cdb,#e7b13e); + box-shadow:0 10px 24px rgba(139,108,219,.26); +} + +.home-demo-preview__buttons{ + display:flex; + align-items:center; + gap:7px; +} + +.home-demo-preview__button{ + display:grid; + gap:1px; + min-width:112px; + padding:9px 14px; + border:1px solid rgba(139,108,219,.18); + border-radius:999px; + background:rgba(246,240,254,.78); + color:#6f6685; + cursor:pointer; + font-family:'Outfit',sans-serif; + text-align:left; + transition:transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease, color .18s ease; +} + +.home-demo-preview__button span{ + font-size:13px; + font-weight:800; + line-height:1.1; +} + +.home-demo-preview__button small{ + color:inherit; + font-size:10px; + font-weight:700; + line-height:1.2; + opacity:.72; +} + +.home-demo-preview__button:hover, +.home-demo-preview__button--active{ + border-color:rgba(231,177,62,.5); + background:linear-gradient(135deg,rgba(139,108,219,.95),rgba(231,177,62,.9)); + color:#fff; + box-shadow:0 12px 30px rgba(139,108,219,.24); + transform:translateY(-1px); +} + +.home-landing img, +.home-landing svg{ + max-width:100%; +} + +.home-modal--wide{ + max-width:1080px!important; +} + +.home-vote-picker{ + display:grid; + grid-template-columns:280px minmax(0,1fr); + min-height:0; + flex:1; + background: + radial-gradient(circle at 82% 8%,rgba(139,108,219,.13),transparent 32%), + #fff; +} + +.home-vote-picker__rail{ + border-right:1px solid #efe7fb; + padding:18px 16px; + overflow-y:auto; + display:flex; + flex-direction:column; + gap:8px; + background:linear-gradient(180deg,#fcfaff 0%,#f7f2ff 100%); +} + +.home-vote-picker__rail-label{ + margin:0 4px 6px; + font-size:11px; + font-weight:800; + letter-spacing:1.7px; + text-transform:uppercase; + color:#a98ddb; +} + +.home-vote-picker__category-button{ + min-height:54px; +} + +.home-vote-picker__content{ + min-width:0; + padding:22px 26px 24px; + overflow-y:auto; +} + +.home-vote-picker__category-header{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:16px; + margin-bottom:18px; +} + +.home-vote-picker__eyebrow{ + margin:0 0 5px; + font-size:12px; + font-weight:800; + letter-spacing:1.8px; + text-transform:uppercase; + color:#a98ddb; +} + +.home-vote-picker__category-header h4{ + margin:0; + font-family:'Cormorant Garamond',serif; + font-size:30px; + line-height:1.05; + color:#3f3556; +} + +.home-vote-picker__hint{ + max-width:230px; + padding:8px 12px; + border-radius:999px; + background:#f4eefd; + color:#7d68bd; + font-size:12px; + font-weight:700; + line-height:1.35; +} + +.home-vote-picker__cards{ + display:grid; + gap:12px; +} + +.home-vote-card{ + display:grid; + grid-template-columns:minmax(210px,1.1fr) minmax(230px,1fr) auto; + align-items:center; + gap:16px; + padding:16px 18px; + border:1.5px solid #eadff8; + border-radius:20px; + background:rgba(255,255,255,.88); + box-shadow:0 14px 34px rgba(82,61,128,.08); + transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease,background .16s ease; +} + +.home-vote-card:hover{ + border-color:#d8c7f2; + box-shadow:0 18px 40px rgba(82,61,128,.13); + transform:translateY(-1px); +} + +.home-vote-card--selected{ + border-color:#8b6cdb; + background:linear-gradient(135deg,#fbf8ff 0%,#f2ebff 100%); + box-shadow:0 18px 46px rgba(139,108,219,.18); +} + +.home-vote-card--missing-clip{ + background:#fff; +} + +.home-vote-card__identity{ + display:flex; + align-items:center; + gap:14px; + min-width:0; +} + +.home-vote-card__avatar{ + flex:none; + width:56px; + height:56px; + border-radius:18px; + display:grid; + place-items:center; + background: + linear-gradient(135deg,rgba(139,108,219,.96),rgba(231,177,62,.9)), + repeating-linear-gradient(45deg,#f3eefb 0 8px,#ece2fa 8px 16px); + color:#fff; + font-family:'Outfit',sans-serif; + font-size:18px; + font-weight:800; + box-shadow:0 12px 24px rgba(139,108,219,.22); +} + +.home-vote-card__name-block{ + min-width:0; +} + +.home-vote-card__name-row{ + display:flex; + align-items:center; + gap:9px; + min-width:0; +} + +.home-vote-card__name-row h5{ + margin:0; + min-width:0; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + font-family:'Outfit',sans-serif; + font-size:18px; + font-weight:800; + color:#332b49; +} + +.home-vote-card__name-row span, +.home-vote-card__clip-platform{ + flex:none; + padding:4px 8px; + border-radius:999px; + background:#f0e8fb; + color:#7d60c6; + font-size:11px; + font-weight:800; + line-height:1; +} + +.home-vote-card__name-block p{ + margin:5px 0 0; + color:#8e86a0; + font-size:14px; + font-weight:600; +} + +.home-vote-card__clip{ + min-width:0; + display:flex; + align-items:center; + gap:10px; + padding:10px 12px; + border-radius:16px; + background:#fbf8ff; + border:1px solid #efe5fb; + color:#7d7491; + font-size:13px; + font-weight:700; +} + +.home-vote-card__clip a{ + min-width:0; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + color:#6d4bc4; + text-decoration:none; +} + +.home-vote-card__clip a:hover{ + color:#4f32a2; + text-decoration:underline; +} + +.home-vote-card--missing-clip .home-vote-card__clip{ + background:#fffaf0; + border-color:#f2dfb6; + color:#9b6a16; +} + +.home-vote-card__pick{ + flex:none; + min-width:116px; + padding:12px 18px; + border:none; + border-radius:14px; + background:#f1ecfb; + color:#8b6cdb; + cursor:pointer; + font-family:'Outfit',sans-serif; + font-size:14px; + font-weight:800; + transition:transform .16s ease,box-shadow .16s ease,background .16s ease; +} + +.home-vote-card__pick:hover{ + transform:translateY(-1px); + box-shadow:0 10px 22px rgba(139,108,219,.18); +} + +.home-vote-card__pick--selected{ + background:linear-gradient(135deg,#8b6cdb,#7355c8); + color:#fff; + box-shadow:0 12px 26px rgba(139,108,219,.26); +} + +.home-vote-picker__empty{ + padding:32px 18px; + border:1px dashed #d8c9f2; + border-radius:18px; + background:#fcfaff; + color:#7d7491; + text-align:center; + font-size:14px; + font-weight:700; +} + +.home-vote-picker__footer{ + display:flex; + align-items:center; + justify-content:space-between; + gap:16px; + padding:18px 36px; + border-top:1px solid #f1ecfb; + background:#fcfaff; +} + +.home-vote-picker__footer div{ + color:#7d7491; + font-size:14px; + font-weight:700; +} + +.home-vote-picker__footer span{ + color:#8b6cdb; + font-weight:900; +} + +.home-vote-picker__footer button{ + padding:13px 28px; + border:none; + border-radius:14px; + background:linear-gradient(135deg,#8b6cdb,#7355c8); + color:#fff; + cursor:pointer; + font-family:'Outfit',sans-serif; + font-size:15px; + font-weight:800; + box-shadow:0 10px 22px rgba(124,86,196,.3); +} + +.home-vote-picker__footer button:disabled, +.home-vote-picker__submit--disabled{ + background:#d1c4e9!important; + color:#9e8cc5!important; + cursor:not-allowed!important; + box-shadow:none!important; +} + +@media (max-width:1080px){ + .home-nav{ + flex-wrap:wrap!important; + align-items:flex-start!important; + gap:12px!important; + padding:12px 18px!important; + } + + .home-nav__brand{ + flex:1 1 260px!important; + min-width:0!important; + } + + .home-nav__links{ + order:3!important; + width:100%!important; + gap:16px!important; + overflow-x:auto!important; + overflow-y:hidden!important; + justify-content:flex-start!important; + padding:2px 2px 7px!important; + scrollbar-width:none; + -webkit-overflow-scrolling:touch; + } + + .home-nav__links::-webkit-scrollbar{ + display:none; + } + + .home-nav__links a{ + flex:0 0 auto!important; + } + + .home-nav__actions{ + flex:0 1 auto!important; + min-width:0!important; + } + + .home-demo-preview{ + align-items:flex-start; + border-radius:28px; + } + + .home-demo-preview__buttons{ + max-width:70vw; + overflow-x:auto; + padding-bottom:2px; + scrollbar-width:none; + } + + .home-demo-preview__buttons::-webkit-scrollbar{ + display:none; + } +} + +@media (max-width:960px){ + .home-hero{ + min-height:auto!important; + } + + .home-hero__content{ + padding:52px 20px 32px!important; + } + + .home-hero__copy{ + max-width:620px!important; + } + + .home-hero__character{ + right:-170px!important; + top:76px!important; + height:780px!important; + opacity:.34!important; + } + + .home-hero__veil{ + background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.94) 44%,rgba(246,240,254,.55) 76%,transparent 100%)!important; + } + + .home-hero__host-card{ + position:relative!important; + right:auto!important; + bottom:auto!important; + margin:0 20px 30px!important; + max-width:620px!important; + z-index:4!important; + } + + .home-stream-band__inner{ + align-items:flex-start!important; + } + + [data-timeline]{ + grid-template-columns:repeat(2,minmax(0,1fr))!important; + } + + [data-cat-grid]{ + grid-template-columns:repeat(2,minmax(0,1fr))!important; + } + + [data-community-grid]{ + grid-template-columns:1fr!important; + } +} + +@media (max-width:760px){ + .home-nav{ + padding:10px 14px!important; + } + + .home-nav__brand{ + flex-basis:100%!important; + font-size:17px!important; + } + + .home-nav__actions{ + order:2!important; + width:100%!important; + justify-content:flex-start!important; + overflow-x:auto!important; + padding-bottom:2px!important; + scrollbar-width:none; + } + + .home-nav__actions::-webkit-scrollbar{ + display:none; + } + + .home-nav__actions > button{ + flex:1 0 auto!important; + justify-content:center!important; + padding:10px 12px!important; + font-size:13px!important; + white-space:nowrap!important; + } + + .home-demo-preview{ + left:12px; + right:12px; + bottom:12px; + max-width:none; + transform:none; + flex-direction:column; + align-items:stretch; + border-radius:24px; + } + + .home-demo-preview__label{ + justify-content:center; + padding:0; + } + + .home-demo-preview__buttons{ + max-width:none; + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + } + + .home-demo-preview__button{ + min-width:0; + text-align:center; + } + + .home-hero__content{ + padding:40px 16px 24px!important; + } + + .home-hero__eyebrow{ + font-size:11px!important; + letter-spacing:1.7px!important; + margin-bottom:16px!important; + } + + .home-hero__presented{ + white-space:normal!important; + font-size:clamp(28px,8vw,38px)!important; + line-height:1.05!important; + margin-bottom:18px!important; + } + + .home-hero__body{ + font-size:16px!important; + max-width:none!important; + } + + .home-hero__phase-card{ + max-width:none!important; + border-radius:18px!important; + padding:20px!important; + } + + .home-hero__phase-title{ + white-space:normal!important; + font-size:24px!important; + } + + .home-hero__host-card{ + margin:0 16px 24px!important; + padding:16px 18px!important; + border-radius:16px!important; + } + + .home-hero__host-name{ + font-size:22px!important; + flex-wrap:wrap!important; + } + + .home-stream-band__inner{ + padding:22px 16px!important; + flex-direction:column!important; + gap:18px!important; + } + + .home-stream-band__lead{ + align-items:flex-start!important; + gap:14px!important; + } + + .home-stream-band__actions{ + width:100%!important; + align-items:stretch!important; + justify-content:center!important; + } + + .home-stream-band__actions > a, + .home-stream-band__actions > div{ + width:100%!important; + justify-content:center!important; + } + + [data-stats]{ + grid-template-columns:repeat(2,minmax(0,1fr))!important; + gap:20px 14px!important; + padding:28px 16px!important; + } + + .home-section{ + padding-left:16px!important; + padding-right:16px!important; + } + + [data-timeline]{ + grid-template-columns:1fr!important; + gap:18px!important; + } + + [data-timeline] > div:first-child{ + display:none!important; + } + + [data-timeline] > div:not(:first-child){ + text-align:left!important; + } + + [data-timeline] > div:not(:first-child) > div:first-child{ + margin:0 0 14px!important; + } + + [data-timeline] > div:not(:first-child) > div:nth-child(2){ + min-height:0!important; + padding:20px!important; + border-radius:18px!important; + } + + [data-timeline] button, + [data-timeline] a{ + width:100%!important; + } + + [data-cat-grid]{ + grid-template-columns:1fr!important; + } + + [data-cat-grid] > div{ + border-radius:18px!important; + padding:22px 20px!important; + } + + .home-winner-card{ + width:min(84vw,360px)!important; + } + + .home-steps-card{ + border-radius:22px!important; + padding:32px 20px!important; + } + + .home-steps-heading{ + white-space:normal!important; + letter-spacing:1.8px!important; + text-align:center!important; + } + + [data-steps]{ + flex-direction:column!important; + gap:24px!important; + } + + [data-step-arrow]{ + display:none!important; + } + + .home-cta-card{ + border-radius:22px!important; + padding:38px 20px!important; + } + + .home-community-card{ + padding:32px 22px!important; + min-height:0!important; + overflow:hidden!important; + } + + .home-community-card__content{ + max-width:100%!important; + } + + .home-community-card__image{ + right:-74px!important; + height:280px!important; + opacity:.18!important; + } + + .home-share-card{ + padding:32px 22px!important; + } + + .home-modal-overlay{ + align-items:flex-start!important; + padding:10px!important; + } + + .home-modal{ + max-height:calc(100dvh - 20px)!important; + border-radius:18px!important; + } + + .home-modal__success, + .home-modal__show, + .home-modal__picker-header, + .home-modal__clip-header{ + padding-left:20px!important; + padding-right:20px!important; + } + + .home-modal__reminder-form{ + flex-direction:column!important; + } + + .home-modal__reminder-form button, + .home-modal__reminder-form input{ + width:100%!important; + } + + .home-modal__picker-grid{ + grid-template-columns:1fr!important; + } + + .home-vote-picker{ + grid-template-columns:1fr!important; + } + + .home-modal__nomination-grid{ + grid-template-columns:1fr!important; + padding:20px!important; + } + + .home-modal__category-rail{ + border-right:none!important; + border-bottom:1px solid #f1ecfb!important; + max-height:210px!important; + } + + .home-vote-picker__rail{ + border-right:none!important; + border-bottom:1px solid #f1ecfb!important; + max-height:210px!important; + } + + .home-modal__nominee-pane{ + max-height:none!important; + } + + .home-vote-picker__content{ + padding:20px!important; + } + + .home-vote-picker__category-header{ + flex-direction:column!important; + } + + .home-vote-picker__hint{ + max-width:none!important; + } + + .home-vote-card{ + grid-template-columns:1fr!important; + align-items:stretch!important; + } + + .home-vote-card__clip{ + align-items:flex-start!important; + flex-direction:column!important; + } + + .home-vote-card__pick{ + width:100%!important; + } + + .home-modal__vote-footer{ + flex-direction:column!important; + align-items:stretch!important; + padding:16px 20px!important; + } + + .home-modal__vote-footer button{ + width:100%!important; + } + + .home-modal__clip{ + max-height:calc(100dvh - 20px)!important; + } + + .home-modal__clip-body{ + padding:20px!important; + } + + .home-modal__clip-grid{ + grid-template-columns:1fr!important; + } + + .home-archive-modal__body{ + grid-template-columns:1fr!important; + } + + .home-archive-modal__years{ + border-right:none!important; + border-bottom:1px solid rgba(139,108,219,.12)!important; + overflow-x:auto!important; + overflow-y:hidden!important; + } + + .home-archive-modal__years > div{ + flex-direction:row!important; + width:max-content!important; + } + + .home-archive-modal__winners{ + grid-template-columns:1fr!important; + } +} + +@media (max-width:480px){ + .home-hero__character{ + right:-220px!important; + top:112px!important; + height:620px!important; + opacity:.22!important; + } + + .home-hero__phase-card [data-dc-ref]{ + font-size:23px!important; + } + + [data-stats]{ + grid-template-columns:repeat(2,minmax(0,1fr))!important; + } + + .home-modal__success{ + padding-top:48px!important; + padding-bottom:34px!important; + } + + .home-vote-card__avatar{ + width:48px!important; + height:48px!important; + border-radius:15px!important; + font-size:16px!important; + } + + .home-vote-card__name-row{ + align-items:flex-start!important; + flex-direction:column!important; + gap:6px!important; + } + + .home-account-data-row{ + align-items:flex-start!important; + flex-direction:column!important; + gap:3px!important; + } + + .home-account-confirm-actions{ + flex-direction:column!important; + } +} diff --git a/frontend/src/components/home/homeLandingTypes.ts b/frontend/src/components/home/homeLandingTypes.ts new file mode 100644 index 0000000..bbf8d56 --- /dev/null +++ b/frontend/src/components/home/homeLandingTypes.ts @@ -0,0 +1,26 @@ +import type { CandidateSummary } from '../../types/awards' + +export type HomeInteractionModalKind = 'show' | 'vote' | 'nominate' | 'clip' + +export type HomePreviewPhase = 'nomination' | 'voting' | 'review' | 'show' | 'completed' + +export type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination' + +export interface HomeClipSubmitContext { + clipUrl: string + selectedNomineeIndex: number + description: string +} + +export interface HomeNominationSubmitContext { + categoryIndex: number + name: string + streamUrl: string +} + +export interface HomeDisplayCategory { + id: string + name: string + icon: string + candidates: CandidateSummary[] +} diff --git a/frontend/src/components/home/homeModalTypes.ts b/frontend/src/components/home/homeModalTypes.ts new file mode 100644 index 0000000..39b6e2b --- /dev/null +++ b/frontend/src/components/home/homeModalTypes.ts @@ -0,0 +1,53 @@ +export interface HomeCategoryListItem { + name: string + icon: string + idx: number + done: boolean + onClick: () => void + rowStyle: string + iconStyle: string + checkStyle: string +} + +export interface HomeNomineeListItem { + name: string + handle: string + platform: string + initials: string + clipUrl: string | null + clipTitle: string + clipPlatform: string + idx: number + selected: boolean + hasClip: boolean + showPick: boolean + onPick: () => void + cardStyle: string + btnStyle: string + btnLabel: string +} + +export interface HomeSelectionOption { + id: number + label: string +} + +export interface HomeArchiveYearItem { + year: number + label: string + winners: Array + active: boolean +} + +export interface HomeArchiveWinnerItem { + category: string + name: string + handle: string + platform: string + url: string +} + +export interface HomeSelectedArchive { + year: number + winners: HomeArchiveWinnerItem[] +} diff --git a/frontend/src/components/home/useHomeArchivePresentation.ts b/frontend/src/components/home/useHomeArchivePresentation.ts new file mode 100644 index 0000000..a7a45c8 --- /dev/null +++ b/frontend/src/components/home/useHomeArchivePresentation.ts @@ -0,0 +1,82 @@ +import { computed, type Ref } from 'vue' + +import { useAwardsStore } from '../../stores/awards' + +type AwardsStore = ReturnType + +export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref) { + const archiveYears = computed(() => { + const knownYears = new Set(store.overview.winnersPreview.map((entry) => entry.year)) + if (store.archive.items.length > 0) { + knownYears.add(store.archive.year) + } + + return [...knownYears] + .sort((left, right) => right - left) + .map((year) => ({ + year, + label: String(year), + winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year), + active: archiveYear.value === year, + })) + }) + + const selectedArchive = computed(() => ({ + year: store.archive.year, + winners: store.archive.items.map((winner) => ({ + category: winner.category, + name: winner.winnerName, + handle: winner.winnerSlug, + platform: winner.winnerPlatform, + url: winner.winnerUrl, + })), + })) + + const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4)) + + function archiveYearButtonStyle(active: boolean) { + return active + ? "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.26);background:linear-gradient(135deg,#2a1842,#3a2168);color:#fff6fb;font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;cursor:pointer;text-align:left;box-shadow:0 14px 28px rgba(20,8,40,.24);" + : "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.16);background:linear-gradient(135deg,#2a1842,#3a2168);color:#fff6fb;font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;cursor:pointer;text-align:left;box-shadow:0 10px 24px rgba(20,8,40,.14);opacity:.9;" + } + + function winnerPlatformKey(url: string) { + const normalized = url.toLowerCase() + if (normalized.includes('twitch.tv')) return 'twitch' + if (normalized.includes('youtube.com') || normalized.includes('youtu.be')) return 'youtube' + if (normalized.includes('x.com') || normalized.includes('twitter.com')) return 'x' + if (normalized.includes('instagram.com')) return 'instagram' + if (normalized.includes('cake.gg') || normalized.includes('cake.')) return 'cake' + return 'link' + } + + function winnerPlatformLabel(url: string) { + const key = winnerPlatformKey(url) + if (key === 'twitch') return 'Twitch' + if (key === 'youtube') return 'YouTube' + if (key === 'x') return 'X' + if (key === 'instagram') return 'Instagram' + if (key === 'cake') return 'Cake' + return 'Profil' + } + + function winnerPlatformStyle(url: string) { + const key = winnerPlatformKey(url) + if (key === 'twitch') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#c9b1ff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;" + if (key === 'youtube') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffb3c1;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;" + if (key === 'x') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#d8d3e6;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;" + if (key === 'instagram') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffb5db;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;" + if (key === 'cake') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#8b6cdb;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;" + return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffd27a;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;" + } + + return { + archiveYears, + selectedArchive, + winnerShowcase, + archiveYearButtonStyle, + winnerPlatformKey, + winnerPlatformLabel, + winnerPlatformStyle, + } +} diff --git a/frontend/src/components/home/useHomeLandingModalState.ts b/frontend/src/components/home/useHomeLandingModalState.ts new file mode 100644 index 0000000..d341c00 --- /dev/null +++ b/frontend/src/components/home/useHomeLandingModalState.ts @@ -0,0 +1,143 @@ +import { computed, ref, type ComputedRef } from 'vue' + +import { useAwardsStore } from '../../stores/awards' + +type AwardsStore = ReturnType + +export function useHomeLandingModalState( + store: AwardsStore, + archiveYears: ComputedRef>, + resetInteractionState: () => void, +) { + const modal = ref(null) + const accountModal = ref(false) + const deleteConfirm = ref(false) + const accountActionError = ref('') + const privacyModal = ref(false) + const archiveModal = ref(false) + const archiveYear = ref(2025) + + const privacyModalOpen = computed(() => privacyModal.value) + const accountModalOpen = computed(() => accountModal.value) + const archiveModalOpen = computed(() => archiveModal.value) + const modalOpen = computed(() => modal.value !== null) + const isShow = computed(() => modal.value === 'show') + const isVote = computed(() => modal.value === 'vote') + const isPicker = computed(() => modal.value === 'vote' || modal.value === 'nominate') + const isClip = computed(() => modal.value === 'clip') + const deleteNotConfirm = computed(() => !deleteConfirm.value) + + function openModal(kind: 'show' | 'vote' | 'nominate' | 'clip') { + modal.value = kind + resetInteractionState() + } + + function closeModal() { + modal.value = null + resetInteractionState() + } + + function openVote(event?: Event) { + event?.preventDefault() + openModal('vote') + } + + function openShow(event?: Event) { + event?.preventDefault() + openModal('show') + } + + function openNominate(event?: Event) { + event?.preventDefault() + openModal('nominate') + } + + function openClip(event?: Event) { + event?.preventDefault() + openModal('clip') + } + + function stop(event: Event) { + event.stopPropagation() + } + + function onOpenAccount() { + accountModal.value = true + deleteConfirm.value = false + accountActionError.value = '' + } + + function onCloseAccount() { + accountModal.value = false + deleteConfirm.value = false + accountActionError.value = '' + } + + function onOpenPrivacy() { + privacyModal.value = true + } + + function onClosePrivacy() { + privacyModal.value = false + } + + async function onOpenArchive() { + archiveModal.value = true + const latestArchiveYear = archiveYears.value[0]?.year ?? store.overview.year - 1 + archiveYear.value = latestArchiveYear + await store.loadArchive(latestArchiveYear) + } + + function onCloseArchive() { + archiveModal.value = false + } + + async function setArchiveYear(year: number) { + archiveYear.value = year + await store.loadArchive(year) + } + + function onRequestDelete() { + deleteConfirm.value = true + } + + function onCancelDelete() { + deleteConfirm.value = false + accountActionError.value = '' + } + + return { + modal, + accountModal, + deleteConfirm, + accountActionError, + privacyModal, + archiveModal, + archiveYear, + privacyModalOpen, + accountModalOpen, + archiveModalOpen, + modalOpen, + isShow, + isVote, + isPicker, + isClip, + deleteNotConfirm, + openModal, + closeModal, + openVote, + openShow, + openNominate, + openClip, + stop, + onOpenAccount, + onCloseAccount, + onOpenPrivacy, + onClosePrivacy, + onOpenArchive, + onCloseArchive, + setArchiveYear, + onRequestDelete, + onCancelDelete, + } +} diff --git a/frontend/src/components/home/useHomeLandingPresentation.ts b/frontend/src/components/home/useHomeLandingPresentation.ts new file mode 100644 index 0000000..f3348a7 --- /dev/null +++ b/frontend/src/components/home/useHomeLandingPresentation.ts @@ -0,0 +1,184 @@ +import { computed, type ComputedRef, type Ref } from 'vue' + +import { useAuthStore } from '../../stores/auth' +import { useAwardsStore } from '../../stores/awards' +import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes' + +type AwardsStore = ReturnType +type AuthStore = ReturnType +type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' + +const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const + +export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore: AuthStore) { + const role = computed<'guest' | 'user' | 'admin'>(() => { + if (!authStore.session) return 'guest' + return authStore.isAdmin ? 'admin' : 'user' + }) + const isGuest = computed(() => role.value === 'guest') + const isUser = computed(() => role.value === 'user') + const isAdmin = computed(() => role.value === 'admin') + const twitchUser = computed(() => authStore.session?.twitchUserId ?? 'local_user') + const siteContent = computed(() => store.overview.siteContent) + const faqItems = computed(() => + (store.overview.faq ?? []).filter((item) => item?.question && item.answer), + ) + const publicStreamUrl = computed(() => store.overview.showStreamUrl || 'https://twitch.tv/jayuhime') + const showDate = computed(() => store.overview.showDate) + const showStartsAt = computed(() => store.overview.showStartsAt || '20:00:00') + const currentYear = computed(() => store.overview.year ? String(store.overview.year) : '') + const displayCategories = computed(() => + store.categories.categories.map((category, index) => ({ + id: String(category.id), + name: category.name, + icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦', + candidates: category.candidates, + })), + ) + const candidateCount = computed(() => + displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0), + ) + const bootstrapArchiveYears = computed>(() => + store.overview.winnersPreview.length > 0 + ? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year })) + : [{ year: store.overview.year - 1 }], + ) + + function timelineItem(key: HomeTimelineKey) { + return store.overview.timeline.find((entry) => entry.key === key) + } + + function formatRange(key: Exclude) { + const item = timelineItem(key) + if (!item) return '' + return `${formatDateLabel(item.startsAt)} – ${formatDateLabel(item.endsAt)}` + } + + function formatTimelineRange(key: HomeTimelineKey) { + const item = timelineItem(key) + if (!item) return 'Noch offen' + return item.startsAt === item.endsAt + ? formatDateLabel(item.startsAt) + : `${formatDateLabel(item.startsAt)} – ${formatDateLabel(item.endsAt)}` + } + + function formatShowDate() { + return formatDateLabel(store.overview.showDate) + } + + return { + role, + isGuest, + isUser, + isAdmin, + twitchUser, + siteContent, + faqItems, + publicStreamUrl, + showDate, + showStartsAt, + currentYear, + displayCategories, + candidateCount, + bootstrapArchiveYears, + formatRange, + formatTimelineRange, + formatShowDate, + initialsFor, + } +} + +export function useHomeModalCandidatePresentation(params: { + displayCategories: ComputedRef + activeCat: Ref + modal: Ref + votes: Ref> + activeCategory: ComputedRef + setCat: (index: number) => void + pickNominee: (categoryId: string, index: number) => void +}) { + const { + displayCategories, + activeCat, + modal, + votes, + activeCategory, + setCat, + pickNominee, + } = params + + const catList = computed(() => buildCatList(displayCategories, activeCat, votes, setCat)) + const noms = computed(() => { + const cat = activeCategory.value + const list = cat?.candidates ?? [] + const voteMode = modal.value === 'vote' + return list.map((candidate, idx) => { + const selected = cat ? votes.value[cat.id] === idx : false + const clipUrl = candidate.clipUrl?.trim() || null + const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen' + const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt' + return { + name: candidate.displayName, + handle: candidate.channelSlug, + platform: candidate.platform, + initials: initialsFor(candidate.displayName), + clipUrl, + clipTitle, + clipPlatform, + idx, + selected, + hasClip: Boolean(clipUrl), + showPick: voteMode, + onPick: () => cat && pickNominee(cat.id, idx), + cardStyle: `display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;transition:all .15s;border:1.5px solid ${selected ? '#8b6cdb;background:#f6f1fd;' : '#ece4f6;background:#fff;'}`, + btnStyle: "flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;transition:all .15s;" + (selected ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#f1ecfb;color:#8b6cdb;'), + btnLabel: selected ? '✓ Gewählt' : 'Auswählen', + } + }) + }) + + return { + catList, + noms, + } +} + +function formatDateLabel(value: string) { + if (!value) return 'Noch nicht terminiert' + const date = new Date(`${value}T00:00:00`) + return Number.isNaN(date.getTime()) + ? value + : date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }) +} + +function initialsFor(value: string) { + return value + .split(/[\s&.-]+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? '') + .join('') +} + +function buildCatList( + displayCategories: ComputedRef, + activeCat: Ref, + votes: Ref>, + setCat: (index: number) => void, +) { + const baseRow = "display:flex;align-items:center;gap:10px;padding:11px 13px;border-radius:11px;cursor:pointer;font-size:14px;font-weight:600;transition:all .15s;border:1px solid transparent;outline:none;-webkit-tap-highlight-color:transparent;text-align:left;width:100%;background:transparent;font-family:'Outfit',sans-serif;" + return displayCategories.value.map((category, index) => { + const active = index === activeCat.value + const done = votes.value[category.id] != null + return { + name: category.name, + icon: category.icon, + idx: index, + done, + onClick: () => setCat(index), + rowStyle: baseRow + (active ? 'background:#f1ecfb;border-color:#d8c9f2;color:#5f44ad;' : 'border-color:transparent;color:#6f6685;'), + iconStyle: "flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;" + (active ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#efe7fb;color:#9a7fce;'), + checkStyle: `flex:none;margin-left:auto;color:#1f9d5a;font-size:14px;font-weight:700;display:${done ? 'inline' : 'none'};`, + } + }) +} diff --git a/frontend/src/components/home/useHomeLandingState.ts b/frontend/src/components/home/useHomeLandingState.ts new file mode 100644 index 0000000..06dccd9 --- /dev/null +++ b/frontend/src/components/home/useHomeLandingState.ts @@ -0,0 +1,380 @@ +import { ref, watch } from 'vue' +import { useRouter } from 'vue-router' + +import { useAuthStore } from '../../stores/auth' +import { useAwardsStore } from '../../stores/awards' +import { useHomeArchivePresentation } from './useHomeArchivePresentation' +import type { HomePreviewPhase } from './homeLandingTypes' +import { useHomeLandingModalState } from './useHomeLandingModalState' +import { + useHomeLandingOverviewPresentation, + useHomeModalCandidatePresentation, +} from './useHomeLandingPresentation' +import { useHomePhasePresentation } from './useHomePhasePresentation' +import { useHomeParticipationState } from './useHomeParticipationState' +import { useHomeSocialPresentation } from './useHomeSocialPresentation' + +export function useHomeLandingState() { + const store = useAwardsStore() + const authStore = useAuthStore() + const router = useRouter() + + const activeCat = ref(0) + const previewPhase = ref('voting') + + const { + role, + isGuest, + isUser, + isAdmin, + twitchUser, + siteContent, + faqItems, + publicStreamUrl, + showDate, + showStartsAt, + currentYear, + displayCategories, + candidateCount, + bootstrapArchiveYears, + formatRange, + formatTimelineRange, + formatShowDate, + initialsFor, + } = useHomeLandingOverviewPresentation(store, authStore) + const { + hostSocialLinks, + communitySocialLinks, + footerLinks, + privacyContentBlocks, + platformKey, + isUploadedSocialIcon, + socialSimpleIconPath, + socialSimpleIconColor, + } = useHomeSocialPresentation(siteContent) + const { + modal, + deleteConfirm, + accountActionError, + archiveYear, + privacyModalOpen, + accountModalOpen, + archiveModalOpen, + modalOpen, + isShow, + isVote, + isPicker, + isClip, + deleteNotConfirm, + openModal, + closeModal, + openVote, + openNominate, + openClip, + stop, + onOpenAccount, + onCloseAccount, + onOpenPrivacy, + onClosePrivacy, + onOpenArchive, + onCloseArchive, + setArchiveYear, + onRequestDelete, + onCancelDelete, + } = useHomeLandingModalState(store, bootstrapArchiveYears, () => { + submitted.value = false + formError.value = '' + successKind.value = null + if (modal.value === 'clip') { + clipDsgvo.value = false + } + }) + const { + archiveYears, + selectedArchive, + winnerShowcase, + archiveYearButtonStyle, + winnerPlatformKey, + winnerPlatformLabel, + winnerPlatformStyle, + } = useHomeArchivePresentation(store, archiveYear) + const { + nominationPhase, + votingPhase, + reviewPhase, + showPhase, + completedPhase, + showCountdown, + streamLive, + streamLocked, + phaseCardTitle, + phaseCardDescription, + phaseCardRange, + phaseStatusLabel, + phaseStatusStyle, + phasePrimaryLabel, + phasePrimaryDisabled, + phasePrimaryActionStyle, + streamEyebrow, + streamTitle, + streamMeta, + streamLockedLabel, + streamLockedTitle, + statOneValue, + statOneLabel, + statTwoValue, + statThreeValue, + statThreeLabel, + categoryIntroText, + timelineLineStyle, + sectionTitle, + sectionText, + sectionActionLabel, + sectionActionHref, + sectionActionDisabled, + sectionActionStyle, + } = useHomePhasePresentation({ + previewPhase, + displayCategories, + candidateCount, + publicStreamUrl, + showDate, + showStartsAt, + currentYear, + formatRange, + formatShowDate, + }) + const { + votes, + submitted, + submitting, + formError, + successKind, + clipDsgvo, + notSubmitted, + voteCount, + totalCats, + canSubmitVote, + activeCategory, + activeCatName, + pickerTitle, + pickerSubtitle, + successTitle, + successText, + clipNomOptions, + catOptions, + canSubmitClip, + clipSubmitStyle, + initializeHomeInteractions, + onLogin, + onLogout, + onConfirmDelete, + openAdminPanel, + setCat, + pickNominee, + submitVote, + submitReminder, + submitNomination, + clipDsgvoChange, + onClipCatChange, + submitClip, + onPrimaryPhaseAction, + onSectionAction, + onTimelineFinalAction, + syncPreviewPhaseFromOverview, + } = useHomeParticipationState({ + store, + authStore, + routerPush: async (path) => { + await router.push(path) + }, + twitchUser, + displayCategories, + archiveYears, + archiveYear, + modal, + previewPhase, + activeCat, + deleteConfirm, + accountActionError, + openModal, + closeAccountModal: onCloseAccount, + }) + const { catList, noms } = useHomeModalCandidatePresentation({ + displayCategories, + activeCat, + modal, + votes, + activeCategory, + setCat, + pickNominee, + }) + + function setPreviewPhase(phase: HomePreviewPhase) { + previewPhase.value = phase + closeModal() + activeCat.value = 0 + submitted.value = false + formError.value = '' + successKind.value = null + clipDsgvo.value = false + } + + function openVoteForPhase(event?: Event) { + if (!votingPhase.value) { + event?.preventDefault() + return + } + + openVote(event) + } + + function openClipForPhase(event?: Event) { + if (!nominationPhase.value) { + event?.preventDefault() + return + } + + openClip(event) + } + + watch(previewPhase, (phase) => { + if ((phase !== 'voting' && modal.value === 'vote') || (phase !== 'nomination' && modal.value === 'clip')) { + closeModal() + } + }) + + return { + store, + authStore, + role, + isGuest, + isUser, + isAdmin, + twitchUser, + siteContent, + hostSocialLinks, + communitySocialLinks, + footerLinks, + faqItems, + privacyContentBlocks, + publicStreamUrl, + displayCategories, + candidateCount, + nominationPhase, + votingPhase, + reviewPhase, + showPhase, + completedPhase, + showCountdown, + streamLive, + streamLocked, + privacyModalOpen, + accountModalOpen, + archiveModalOpen, + modalOpen, + isShow, + isVote, + isPicker, + isClip, + notSubmitted, + deleteNotConfirm, + voteCount, + totalCats, + canSubmitVote, + activeCatName, + phaseCardTitle, + phaseCardDescription, + phaseCardRange, + phaseStatusLabel, + phaseStatusStyle, + phasePrimaryLabel, + phasePrimaryDisabled, + phasePrimaryActionStyle, + streamEyebrow, + streamTitle, + streamMeta, + streamLockedLabel, + streamLockedTitle, + statOneValue, + statOneLabel, + statTwoValue, + statThreeValue, + statThreeLabel, + categoryIntroText, + timelineLineStyle, + sectionTitle, + sectionText, + sectionActionLabel, + sectionActionHref, + sectionActionDisabled, + sectionActionStyle, + previewPhase, + pickerTitle, + pickerSubtitle, + successTitle, + successText, + clipNomOptions, + catOptions, + canSubmitClip, + clipSubmitStyle, + archiveYears, + selectedArchive, + winnerShowcase, + activeCat, + submitted, + submitting, + formError, + clipDsgvo, + archiveYear, + modal, + catList, + noms, + formatTimelineRange, + formatShowDate, + initialsFor, + openClip: openClipForPhase, + openVote: openVoteForPhase, + openNominate, + onLogin, + onOpenAccount, + openAdminPanel, + onPrimaryPhaseAction, + isUploadedSocialIcon, + socialSimpleIconPath, + socialSimpleIconColor, + platformKey, + onSectionAction, + closeModal, + stop, + submitReminder, + submitVote, + submitNomination, + onClipCatChange, + clipDsgvoChange, + onOpenPrivacy, + submitClip, + onCloseArchive, + archiveYearButtonStyle, + winnerPlatformStyle, + winnerPlatformKey, + winnerPlatformLabel, + privacyModalStop: stop, + accountModalStop: stop, + archiveModalStop: stop, + onClosePrivacy, + onCloseAccount, + deleteConfirm, + accountActionError, + onLogout, + onRequestDelete, + onCancelDelete, + onConfirmDelete, + onOpenArchive, + setArchiveYear, + onTimelineFinalAction, + setPreviewPhase, + syncPreviewPhaseFromOverview, + initializeHomeInteractions, + } +} diff --git a/frontend/src/components/home/useHomeLandingViewEffects.ts b/frontend/src/components/home/useHomeLandingViewEffects.ts new file mode 100644 index 0000000..8aef19a --- /dev/null +++ b/frontend/src/components/home/useHomeLandingViewEffects.ts @@ -0,0 +1,380 @@ +import { nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance, type Ref } from 'vue' +import type { Router } from 'vue-router' + +import { useAuthStore } from '../../stores/auth' +import { useAwardsStore } from '../../stores/awards' +import type { HomeNominationSubmitContext } from './homeLandingTypes' + +type AuthStore = ReturnType +type AwardsStore = ReturnType +type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed' + +interface PhaseCountdownTarget { + label: string + target: number + direction?: 'until' | 'since' +} + +interface TimelineScheduleItem { + key: HomeTimelineKey + title: string + startMs: number + endMs: number +} + +interface UseHomeLandingViewEffectsParams { + router: Router + store: AwardsStore + authStore: AuthStore + modalOpen: Readonly> + accountModalOpen: Readonly> + privacyModalOpen: Readonly> + archiveModalOpen: Readonly> + submitted: Readonly> + streamLive: Readonly> + archiveYear: Readonly> + nominationPhase: Readonly> + votingPhase: Readonly> + reviewPhase: Readonly> + completedPhase: Readonly> + initializeHomeInteractions: () => Promise + submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise + submitClip: (clipContext: { clipUrl: string; selectedNomineeIndex: number; description: string }) => Promise +} + +export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) { + const { + router, + store, + authStore, + modalOpen, + accountModalOpen, + privacyModalOpen, + archiveModalOpen, + submitted, + streamLive, + archiveYear, + nominationPhase, + votingPhase, + reviewPhase, + completedPhase, + initializeHomeInteractions, + submitNomination, + submitClip, + } = params + + const rootEl = ref(null) + const landingLoaderVisible = ref(true) + const nominationCatEl = ref(null) + const nominationNameEl = ref(null) + const nominationStreamUrlEl = ref(null) + const clipUrlEl = ref(null) + const clipNomEl = ref(null) + const clipDescEl = ref(null) + const countdownRefs = { + labelEl: ref(null), + streamLabelEl: ref(null), + dEl: ref(null), + hEl: ref(null), + mEl: ref(null), + sEl: ref(null), + bdEl: ref(null), + bhEl: ref(null), + bmEl: ref(null), + bsEl: ref(null), + } + let timer: ReturnType | null = null + let landingLoaderTimer: ReturnType | null = null + + function setRootEl(element: Element | ComponentPublicInstance | null) { + rootEl.value = element instanceof HTMLElement ? element : null + } + + function handleClipSubmit() { + return submitClip({ + clipUrl: clipUrlEl.value?.value.trim() ?? '', + selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0, + description: clipDescEl.value?.value.trim() ?? '', + }) + } + + function handleNominationSubmit() { + return submitNomination({ + categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0, + name: nominationNameEl.value?.value.trim() ?? '', + streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '', + }) + } + + function assignDomRefs() { + const root = rootEl.value + if (!root) return + countdownRefs.labelEl.value = root.querySelector('[data-dc-ref="phaseCountdownLabelRef"]') + countdownRefs.streamLabelEl.value = root.querySelector('[data-dc-ref="streamCountdownLabelRef"]') + countdownRefs.dEl.value = root.querySelector('[data-dc-ref="dRef"]') + countdownRefs.hEl.value = root.querySelector('[data-dc-ref="hRef"]') + countdownRefs.mEl.value = root.querySelector('[data-dc-ref="mRef"]') + countdownRefs.sEl.value = root.querySelector('[data-dc-ref="sRef"]') + countdownRefs.bdEl.value = root.querySelector('[data-dc-ref="bdRef"]') + countdownRefs.bhEl.value = root.querySelector('[data-dc-ref="bhRef"]') + countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]') + countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]') + nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]') + nominationNameEl.value = root.querySelector('[data-dc-ref="nominationNameRef"]') + nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]') + clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]') + clipNomEl.value = root.querySelector('[data-dc-ref="clipNomRef"]') + clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]') + } + + function wireInteractiveStyles() { + const root = rootEl.value + if (!root) return + root.querySelectorAll('[style-hover]').forEach((element) => { + if (element.dataset.hoverBound === '1') return + element.dataset.hoverBound = '1' + const base = element.getAttribute('style') ?? '' + const hover = element.getAttribute('style-hover') ?? '' + element.addEventListener('mouseenter', () => { element.setAttribute('style', `${base}${hover}`) }) + element.addEventListener('mouseleave', () => { element.setAttribute('style', base) }) + }) + root.querySelectorAll('[style-focus]').forEach((element) => { + if (element.dataset.focusBound === '1') return + element.dataset.focusBound = '1' + const base = element.getAttribute('style') ?? '' + const focus = element.getAttribute('style-focus') ?? '' + element.addEventListener('focus', () => { element.setAttribute('style', `${base}${focus}`) }) + element.addEventListener('blur', () => { element.setAttribute('style', base) }) + }) + } + + function setupDom() { + assignDomRefs() + wireInteractiveStyles() + } + + function tick() { + const now = Date.now() + const activeTarget = resolvePhaseCountdownTarget(store, resolveSelectedPhaseKey(), now) + const showTarget = parseShowStartMs(store) + const activeTargetMs = Number.isNaN(activeTarget.target) ? showTarget : activeTarget.target + const activeCountdown = split(activeTarget.direction === 'since' ? now - activeTargetMs : activeTargetMs - now) + const showCompleted = resolveIsCompletedPhase(store.overview.currentPhase) + const showStarted = !Number.isNaN(showTarget) && now >= showTarget + const showCountdown = split(showCompleted ? 0 : showStarted ? now - showTarget : showTarget - now) + + setText(countdownRefs.labelEl.value, activeTarget.label) + setText(countdownRefs.streamLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Stream läuft seit' : 'Finale startet in') + setText(countdownRefs.dEl.value, pad(activeCountdown.d)) + setText(countdownRefs.hEl.value, pad(activeCountdown.h)) + setText(countdownRefs.mEl.value, pad(activeCountdown.m)) + setText(countdownRefs.sEl.value, pad(activeCountdown.s)) + setText(countdownRefs.bdEl.value, pad(showCountdown.d)) + setText(countdownRefs.bhEl.value, pad(showCountdown.h)) + setText(countdownRefs.bmEl.value, pad(showCountdown.m)) + setText(countdownRefs.bsEl.value, pad(showCountdown.s)) + } + + function resolveSelectedPhaseKey(): HomeTimelineKey { + if (nominationPhase.value) return 'nomination' + if (votingPhase.value) return 'voting' + if (reviewPhase.value) return 'review' + if (completedPhase.value) return 'completed' + return 'show' + } + + watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => { + document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : '' + nextTick(setupDom) + }) + + watch([submitted, streamLive, archiveYear], () => { + nextTick(setupDom) + }) + + onMounted(async () => { + if (!authStore.hydrated) { + await authStore.hydrate() + } + await store.loadHomeData() + if (store.lastPublicErrorKind) { + await router.replace({ + name: 'network-error', + query: { from: '/' }, + }) + return + } + await initializeHomeInteractions() + setupDom() + tick() + timer = setInterval(tick, 1000) + landingLoaderTimer = setTimeout(() => { + landingLoaderVisible.value = false + }, 1500) + }) + + onBeforeUnmount(() => { + document.body.style.overflow = '' + if (timer) clearInterval(timer) + if (landingLoaderTimer) clearTimeout(landingLoaderTimer) + }) + + return { + setRootEl, + landingLoaderVisible, + handleNominationSubmit, + handleClipSubmit, + } +} + +function pad(value: number) { + return String(value).padStart(2, '0') +} + +function parseBackendDateMs(value: string, endOfDay = false) { + if (!value) return Number.NaN + const date = new Date(`${value}T${endOfDay ? '23:59:59' : '00:00:00'}`) + return date.getTime() +} + +function parseShowStartMs(store: AwardsStore) { + const date = store.overview.showDate + if (!date) return Number.NaN + const time = normalizeBackendTime(store.overview.showStartsAt) + return new Date(`${date}T${time}`).getTime() +} + +function normalizeBackendTime(value: string) { + if (!value) return '20:00:00' + return value.length === 5 ? `${value}:00` : value +} + +function resolvePhaseCountdownTarget( + store: AwardsStore, + selectedPhaseKey: HomeTimelineKey, + now: number, +): PhaseCountdownTarget { + if (selectedPhaseKey === 'completed' || resolveIsCompletedPhase(store.overview.currentPhase)) { + return { + label: 'Award-Jahr abgeschlossen', + target: now, + } + } + + const schedule = buildTimelineSchedule(store) + const selectedIndex = schedule.findIndex((item) => item.key === selectedPhaseKey) + const selectedItem = selectedIndex >= 0 ? schedule[selectedIndex] : null + + if (selectedItem) { + const selectedTarget = resolveItemCountdownTarget(selectedItem, now) + if (selectedTarget) { + return selectedTarget + } + + const nextTarget = schedule + .slice(selectedIndex + 1) + .map((item) => resolveItemCountdownTarget(item, now)) + .find((target): target is PhaseCountdownTarget => Boolean(target)) + + if (nextTarget) { + return nextTarget + } + } + + const globalTarget = schedule + .map((item) => resolveItemCountdownTarget(item, now)) + .find((target): target is PhaseCountdownTarget => Boolean(target)) + + if (globalTarget) { + return globalTarget + } + + const showTarget = parseBackendDateMs(store.overview.showDate, true) + return { + label: 'Phase abgeschlossen', + target: Number.isNaN(showTarget) ? now : showTarget, + } +} + +function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] { + const phaseOrder: Exclude[] = ['nomination', 'voting', 'review', 'show'] + + return phaseOrder + .map((key): TimelineScheduleItem | null => { + const entry = store.overview.timeline.find((item) => item.key === key) + const fallbackDate = key === 'show' ? store.overview.showDate : '' + const startsAt = entry?.startsAt || fallbackDate + const endsAt = entry?.endsAt || fallbackDate + const startMs = key === 'show' ? parseShowStartMs(store) : parseBackendDateMs(startsAt) + const endMs = parseBackendDateMs(endsAt, true) + + if (Number.isNaN(startMs) || Number.isNaN(endMs)) { + return null + } + + return { + key, + title: entry?.title || phaseTitle(key), + startMs, + endMs, + } + }) + .filter((item): item is TimelineScheduleItem => item !== null) +} + +function resolveItemCountdownTarget(item: TimelineScheduleItem, now: number): PhaseCountdownTarget | null { + if (now < item.startMs) { + return { + label: `${item.title} startet in`, + target: item.startMs, + } + } + + if (now <= item.endMs) { + if (item.key === 'show') { + return { + label: 'Award-Show läuft seit', + target: item.startMs, + direction: 'since', + } + } + + return { + label: `${item.title} noch offen`, + target: item.endMs, + } + } + + return null +} + +function resolveIsCompletedPhase(currentPhase: string) { + const value = currentPhase.trim().toLowerCase() + return value.includes('abgeschlossen') || value.includes('archiv') || value.includes('complete') || value.includes('ended') +} + +function phaseTitle(key: HomeTimelineKey) { + return key === 'nomination' + ? 'Nominierung' + : key === 'voting' + ? 'Voting' + : key === 'review' + ? 'Review & Auswertung' + : 'Award Show' +} + +function split(milliseconds: number) { + const seconds = Math.floor(Math.max(0, milliseconds) / 1000) + return { + d: Math.floor(seconds / 86400), + h: Math.floor((seconds % 86400) / 3600), + m: Math.floor((seconds % 3600) / 60), + s: seconds % 60, + } +} + +function setText(element: HTMLElement | null, value: string) { + if (element) { + element.textContent = value + } +} diff --git a/frontend/src/components/home/useHomeParticipationActions.ts b/frontend/src/components/home/useHomeParticipationActions.ts new file mode 100644 index 0000000..b0f973e --- /dev/null +++ b/frontend/src/components/home/useHomeParticipationActions.ts @@ -0,0 +1,189 @@ +import { watch, type ComputedRef, type Ref } from 'vue' + +import type { AuthStore } from '../../stores/auth' +import type { AwardsStore } from '../../stores/awards' +import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes' +import { useHomeParticipationSessionActions } from './useHomeParticipationSessionActions' +import { useHomeParticipationSubmitActions } from './useHomeParticipationSubmitActions' + +export function useHomeParticipationActions(params: { + store: AwardsStore + authStore: AuthStore + routerPush: (path: string) => Promise + twitchUser: ComputedRef + displayCategories: ComputedRef + archiveYears: ComputedRef> + archiveYear: Ref + previewPhase: Ref + activeCat: Ref + deleteConfirm: Ref + accountActionError: Ref + openModal: (kind: HomeInteractionModalKind) => void + closeAccountModal: () => void + votes: Ref> + submitted: Ref + submitting: Ref + formError: Ref + successKind: Ref + clipCatIdx: Ref + clipDsgvo: Ref +}) { + const { + store, + authStore, + routerPush, + twitchUser, + displayCategories, + archiveYears, + archiveYear, + previewPhase, + activeCat, + deleteConfirm, + accountActionError, + openModal, + closeAccountModal, + votes, + submitted, + submitting, + formError, + successKind, + clipCatIdx, + clipDsgvo, + } = params + + const { + ensureViewerSession, + loadMyParticipation, + onLogin, + onLogout, + onConfirmDelete, + openAdminPanel, + } = useHomeParticipationSessionActions({ + store, + authStore, + routerPush, + displayCategories, + votes, + formError, + deleteConfirm, + accountActionError, + clipDsgvo, + closeAccountModal, + }) + const { + setCat, + pickNominee, + submitVote, + submitReminder, + submitNomination, + clipDsgvoChange, + onClipCatChange, + submitClip, + } = useHomeParticipationSubmitActions({ + store, + displayCategories, + activeCat, + previewPhase, + votes, + submitted, + submitting, + formError, + successKind, + clipCatIdx, + clipDsgvo, + ensureViewerSession, + loadMyParticipation, + fallbackCreatorName: twitchUser, + }) + + async function initializeHomeInteractions() { + syncPreviewPhaseFromOverview() + await loadMyParticipation() + archiveYear.value = archiveYears.value[0]?.year ?? store.overview.year - 1 + } + + function onPrimaryPhaseAction(event?: Event) { + if (previewPhase.value === 'nomination') { + event?.preventDefault() + openModal('nominate') + return + } + if (previewPhase.value === 'voting') { + event?.preventDefault() + openModal('vote') + return + } + if (previewPhase.value === 'review') { + event?.preventDefault() + return + } + } + + function onSectionAction(event?: Event) { + onPrimaryPhaseAction(event) + } + + function onTimelineFinalAction(event?: Event) { + if (previewPhase.value === 'show') return + event?.preventDefault() + openModal('show') + } + + function syncPreviewPhaseFromOverview() { + const activeTimelineItem = store.overview.timeline.find((entry) => entry.state === 'active') + if (isHomePreviewPhaseKey(activeTimelineItem?.key)) { + previewPhase.value = activeTimelineItem.key + return + } + + if (isCompletedPhase(store.overview.currentPhase)) { + previewPhase.value = 'completed' + } + } + + watch(displayCategories, (categories) => { + if (activeCat.value >= categories.length) { + activeCat.value = 0 + } + if (clipCatIdx.value >= categories.length) { + clipCatIdx.value = 0 + } + }) + + watch(() => store.overview.currentPhase, () => { + syncPreviewPhaseFromOverview() + }) + + watch(() => authStore.session?.twitchUserId, () => { + void loadMyParticipation() + }) + + return { + initializeHomeInteractions, + onLogin, + onLogout, + onConfirmDelete, + openAdminPanel, + setCat, + pickNominee, + submitVote, + submitReminder, + submitNomination, + clipDsgvoChange, + onClipCatChange, + submitClip, + onPrimaryPhaseAction, + onSectionAction, + onTimelineFinalAction, + syncPreviewPhaseFromOverview, + } +} + +function isHomePreviewPhaseKey(value: string | undefined): value is HomePreviewPhase { + return value === 'nomination' || value === 'voting' || value === 'review' || value === 'show' +} + +function isCompletedPhase(value: string) { + const normalized = value.trim().toLowerCase() + return normalized.includes('abgeschlossen') || normalized.includes('archiv') || normalized.includes('complete') || normalized.includes('ended') +} diff --git a/frontend/src/components/home/useHomeParticipationPresentation.ts b/frontend/src/components/home/useHomeParticipationPresentation.ts new file mode 100644 index 0000000..4830085 --- /dev/null +++ b/frontend/src/components/home/useHomeParticipationPresentation.ts @@ -0,0 +1,71 @@ +import { computed, type ComputedRef, type Ref } from 'vue' + +import type { AwardsStore } from '../../stores/awards' +import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes' + +export function useHomeParticipationPresentation(params: { + store: AwardsStore + displayCategories: ComputedRef + activeCat: Ref + modal: Ref + previewPhase: Ref + votes: Ref> + submitted: Ref + successKind: Ref + clipCatIdx: Ref + clipDsgvo: Ref +}) { + const { + store, + displayCategories, + activeCat, + modal, + previewPhase, + votes, + submitted, + successKind, + clipCatIdx, + clipDsgvo, + } = params + + const notSubmitted = computed(() => !submitted.value) + const voteCount = computed(() => Object.keys(votes.value).length) + const totalCats = computed(() => displayCategories.value.length) + const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0) + const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null) + const activeCatName = computed(() => activeCategory.value?.name ?? '') + const pickerTitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Streamer nominieren' : modal.value === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt')) + const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche Name und Stream-Link ein. Optional kannst du direkt einen Clip mitschicken.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.')) + const successTitle = computed(() => successKind.value === 'nomination' ? 'Nominierung eingereicht ✦' : successKind.value === 'clip' ? 'Clip eingereicht ✦' : successKind.value === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩') + const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Name und Stream-Link wurden gespeichert und landen im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.') + const clipNomOptions = computed(() => (displayCategories.value[clipCatIdx.value]?.candidates ?? []).map((candidate, index) => ({ id: index, label: `${candidate.displayName}` }))) + const catOptions = computed(() => displayCategories.value.map((category, index) => ({ id: index, label: `${category.icon} ${category.name}` }))) + const canSubmitClip = computed(() => previewPhase.value === 'nomination' && clipDsgvo.value) + const clipSubmitStyle = computed(() => canSubmitClip.value ? "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" : "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;") + + return { + notSubmitted, + voteCount, + totalCats, + canSubmitVote, + activeCategory, + activeCatName, + pickerTitle, + pickerSubtitle, + successTitle, + successText, + clipNomOptions, + catOptions, + canSubmitClip, + clipSubmitStyle, + } +} + +function formatShowDate(store: AwardsStore) { + const value = store.overview.showDate + if (!value) return 'Noch nicht terminiert' + const date = new Date(`${value}T00:00:00`) + return Number.isNaN(date.getTime()) + ? value + : date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }) +} diff --git a/frontend/src/components/home/useHomeParticipationSessionActions.ts b/frontend/src/components/home/useHomeParticipationSessionActions.ts new file mode 100644 index 0000000..1f090cd --- /dev/null +++ b/frontend/src/components/home/useHomeParticipationSessionActions.ts @@ -0,0 +1,112 @@ +import type { ComputedRef, Ref } from 'vue' + +import { api } from '../../lib/api' +import type { AuthSession } from '../../types/awards' +import type { AuthStore } from '../../stores/auth' +import type { AwardsStore } from '../../stores/awards' +import type { HomeDisplayCategory } from './homeLandingTypes' + +export function useHomeParticipationSessionActions(params: { + store: AwardsStore + authStore: AuthStore + routerPush: (path: string) => Promise + displayCategories: ComputedRef + votes: Ref> + formError: Ref + deleteConfirm: Ref + accountActionError: Ref + clipDsgvo: Ref + closeAccountModal: () => void +}) { + const { + store, + authStore, + routerPush, + displayCategories, + votes, + formError, + deleteConfirm, + accountActionError, + clipDsgvo, + closeAccountModal, + } = params + + async function ensureViewerSession(): Promise { + if (authStore.session) return authStore.session + + await authStore.login({ + twitchUserId: 'local_user', + displayName: 'Local User', + role: 'viewer', + }) + + if (!authStore.session) { + throw new Error('Eine aktive Session konnte nicht erstellt werden.') + } + + return authStore.session + } + + async function loadMyParticipation() { + if (!authStore.session || !store.overview.year) return + try { + const participation = await api.getMyParticipation(store.overview.year) + const nextVotes: Record = {} + for (const vote of participation.votes) { + const category = displayCategories.value.find((item) => Number(item.id) === vote.categoryId) + const candidateIndex = category?.candidates.findIndex((candidate) => candidate.id === vote.candidateId) ?? -1 + if (category && candidateIndex >= 0) { + nextVotes[category.id] = candidateIndex + } + } + votes.value = nextVotes + } catch { + votes.value = {} + } + } + + async function onLogin() { + formError.value = '' + await ensureViewerSession() + await loadMyParticipation() + } + + async function onLogout() { + await authStore.logout() + closeAccountModal() + deleteConfirm.value = false + accountActionError.value = '' + clipDsgvo.value = false + votes.value = {} + await routerPush('/login') + } + + async function onConfirmDelete() { + accountActionError.value = '' + try { + await authStore.deleteMyData() + closeAccountModal() + deleteConfirm.value = false + clipDsgvo.value = false + votes.value = {} + await store.loadHomeData() + await routerPush('/login') + } catch (error) { + accountActionError.value = error instanceof Error ? error.message : 'Deine Daten konnten gerade nicht gelöscht werden.' + } + } + + async function openAdminPanel(event?: Event) { + event?.preventDefault() + await routerPush(authStore.isAdmin ? '/admin' : '/login?redirect=/admin') + } + + return { + ensureViewerSession, + loadMyParticipation, + onLogin, + onLogout, + onConfirmDelete, + openAdminPanel, + } +} diff --git a/frontend/src/components/home/useHomeParticipationState.ts b/frontend/src/components/home/useHomeParticipationState.ts new file mode 100644 index 0000000..61d4371 --- /dev/null +++ b/frontend/src/components/home/useHomeParticipationState.ts @@ -0,0 +1,160 @@ +import { ref, type ComputedRef, type Ref } from 'vue' + +import { useAuthStore } from '../../stores/auth' +import { useAwardsStore } from '../../stores/awards' +import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes' +import { useHomeParticipationActions } from './useHomeParticipationActions' +import { useHomeParticipationPresentation } from './useHomeParticipationPresentation' + +type AuthStore = ReturnType +type AwardsStore = ReturnType + +export function useHomeParticipationState(params: { + store: AwardsStore + authStore: AuthStore + routerPush: (path: string) => Promise + twitchUser: ComputedRef + displayCategories: ComputedRef + archiveYears: ComputedRef> + archiveYear: Ref + modal: Ref + previewPhase: Ref + activeCat: Ref + deleteConfirm: Ref + accountActionError: Ref + openModal: (kind: HomeInteractionModalKind) => void + closeAccountModal: () => void +}) { + const { + store, + authStore, + routerPush, + twitchUser, + displayCategories, + archiveYears, + archiveYear, + modal, + previewPhase, + activeCat, + deleteConfirm, + accountActionError, + openModal, + closeAccountModal, + } = params + + const votes = ref>({}) + const submitted = ref(false) + const submitting = ref(false) + const formError = ref('') + const successKind = ref(null) + const clipCatIdx = ref(0) + const clipDsgvo = ref(false) + const { + notSubmitted, + voteCount, + totalCats, + canSubmitVote, + activeCategory, + activeCatName, + pickerTitle, + pickerSubtitle, + successTitle, + successText, + clipNomOptions, + catOptions, + canSubmitClip, + clipSubmitStyle, + } = useHomeParticipationPresentation({ + store, + displayCategories, + activeCat, + modal, + previewPhase, + votes, + submitted, + successKind, + clipCatIdx, + clipDsgvo, + }) + const { + initializeHomeInteractions, + onLogin, + onLogout, + onConfirmDelete, + openAdminPanel, + setCat, + pickNominee, + submitVote, + submitReminder, + submitNomination, + clipDsgvoChange, + onClipCatChange, + submitClip, + onPrimaryPhaseAction, + onSectionAction, + onTimelineFinalAction, + syncPreviewPhaseFromOverview, + } = useHomeParticipationActions({ + store, + authStore, + routerPush, + twitchUser, + displayCategories, + archiveYears, + archiveYear, + previewPhase, + activeCat, + deleteConfirm, + accountActionError, + openModal, + closeAccountModal, + votes, + submitted, + submitting, + formError, + successKind, + clipCatIdx, + clipDsgvo, + }) + + return { + votes, + submitted, + submitting, + formError, + successKind, + clipCatIdx, + clipDsgvo, + notSubmitted, + voteCount, + totalCats, + canSubmitVote, + activeCategory, + activeCatName, + pickerTitle, + pickerSubtitle, + successTitle, + successText, + clipNomOptions, + catOptions, + canSubmitClip, + clipSubmitStyle, + initializeHomeInteractions, + onLogin, + onLogout, + onConfirmDelete, + openAdminPanel, + setCat, + pickNominee, + submitVote, + submitReminder, + submitNomination, + clipDsgvoChange, + onClipCatChange, + submitClip, + onPrimaryPhaseAction, + onSectionAction, + onTimelineFinalAction, + syncPreviewPhaseFromOverview, + } +} diff --git a/frontend/src/components/home/useHomeParticipationSubmitActions.ts b/frontend/src/components/home/useHomeParticipationSubmitActions.ts new file mode 100644 index 0000000..05d45b5 --- /dev/null +++ b/frontend/src/components/home/useHomeParticipationSubmitActions.ts @@ -0,0 +1,214 @@ +import type { ComputedRef, Ref } from 'vue' + +import type { AuthSession } from '../../types/awards' +import type { AwardsStore } from '../../stores/awards' +import type { HomeClipSubmitContext, HomeDisplayCategory, HomeNominationSubmitContext, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes' + +export function useHomeParticipationSubmitActions(params: { + store: AwardsStore + displayCategories: ComputedRef + activeCat: Ref + previewPhase: Ref + votes: Ref> + submitted: Ref + submitting: Ref + formError: Ref + successKind: Ref + clipCatIdx: Ref + clipDsgvo: Ref + ensureViewerSession: () => Promise + loadMyParticipation: () => Promise + fallbackCreatorName: ComputedRef +}) { + const { + store, + displayCategories, + activeCat, + previewPhase, + votes, + submitted, + submitting, + formError, + successKind, + clipCatIdx, + clipDsgvo, + ensureViewerSession, + loadMyParticipation, + fallbackCreatorName, + } = params + + function setCat(index: number) { + activeCat.value = index + } + + function pickNominee(categoryId: string, index: number) { + votes.value = { ...votes.value, [categoryId]: index } + } + + async function submitVote() { + formError.value = '' + if (submitting.value) return + if (previewPhase.value !== 'voting') { + formError.value = 'Das Voting ist in dieser Phase nicht verfügbar.' + return + } + + if (Object.keys(votes.value).length === 0) { + formError.value = 'Bitte wähle mindestens eine Kategorie aus.' + return + } + + submitting.value = true + try { + const session = await ensureViewerSession() + const entries = Object.entries(votes.value) + .map(([categoryId, candidateIndex]) => { + const category = displayCategories.value.find((item) => item.id === categoryId) + const candidate = category?.candidates[candidateIndex] + return category && candidate + ? { categoryId: Number(category.id), candidateId: candidate.id } + : null + }) + .filter((entry: { categoryId: number; candidateId: number } | null): entry is { categoryId: number; candidateId: number } => entry !== null) + + if (entries.length === 0) { + formError.value = 'Bitte wähle mindestens eine gültige Kategorie aus.' + return + } + + await store.submitVote({ + seasonId: store.overview.seasonId, + twitchUserId: session.twitchUserId, + entries, + }) + await loadMyParticipation() + submitted.value = true + successKind.value = 'vote' + } catch (error) { + formError.value = error instanceof Error ? error.message : 'Deine Stimme konnte nicht gespeichert werden.' + } finally { + submitting.value = false + } + } + + function submitReminder() { + submitted.value = true + successKind.value = 'show' + } + + function clipDsgvoChange() { + clipDsgvo.value = !clipDsgvo.value + } + + function onClipCatChange(event: Event) { + const target = event.target as HTMLSelectElement + clipCatIdx.value = Number.parseInt(target.value || '0', 10) || 0 + } + + async function submitNomination(nominationContext: HomeNominationSubmitContext) { + formError.value = '' + if (submitting.value) return + if (previewPhase.value !== 'nomination') { + formError.value = 'Nominierungen sind nur während der Nominierungsphase möglich.' + return + } + + const name = nominationContext.name.trim() + const streamUrl = nominationContext.streamUrl.trim() + if (!name) { + formError.value = 'Bitte gib den Namen des VTubers oder Streamers ein.' + return + } + + if (!streamUrl) { + formError.value = 'Bitte füge einen Stream- oder Kanal-Link hinzu.' + return + } + + if (!isHttpUrl(streamUrl)) { + formError.value = 'Bitte gib einen gültigen http(s)-Link ein.' + return + } + + const category = displayCategories.value[nominationContext.categoryIndex] + if (!category) { + formError.value = 'Bitte wähle eine gültige Kategorie aus.' + return + } + + submitting.value = true + try { + const session = await ensureViewerSession() + await store.submitNomination({ + year: store.overview.year, + categoryId: Number(category.id), + twitchUserId: session.twitchUserId, + nominations: [{ name, streamUrl }], + }) + await loadMyParticipation() + submitted.value = true + successKind.value = 'nomination' + } catch (error) { + formError.value = error instanceof Error ? error.message : 'Deine Nominierung konnte nicht gespeichert werden.' + } finally { + submitting.value = false + } + } + + async function submitClip(clipContext: HomeClipSubmitContext) { + formError.value = '' + if (!clipDsgvo.value || submitting.value) return + if (previewPhase.value !== 'nomination') { + formError.value = 'Clip-Einreichungen sind nur während der Nominierungsphase möglich.' + return + } + + const url = clipContext.clipUrl.trim() + if (!url) { + formError.value = 'Bitte gib einen Twitch- oder YouTube-Clip-Link ein.' + return + } + + submitting.value = true + try { + const session = await ensureViewerSession() + const category = displayCategories.value[clipCatIdx.value] + const selectedCreator = category?.candidates[clipContext.selectedNomineeIndex] + await store.submitClip({ + year: store.overview.year, + categoryId: category ? Number(category.id) : null, + candidateId: selectedCreator?.id ?? null, + twitchUserId: session.twitchUserId, + clipUrl: url, + title: clipContext.description || `Clip fuer ${selectedCreator?.displayName ?? 'die Award-Show'}`, + creator: selectedCreator?.displayName ?? fallbackCreatorName.value, + }) + submitted.value = true + successKind.value = 'clip' + } catch (error) { + formError.value = error instanceof Error ? error.message : 'Der Clip konnte nicht gespeichert werden.' + } finally { + submitting.value = false + } + } + + return { + setCat, + pickNominee, + submitVote, + submitReminder, + clipDsgvoChange, + onClipCatChange, + submitNomination, + submitClip, + } +} + +function isHttpUrl(value: string) { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} diff --git a/frontend/src/components/home/useHomePhasePresentation.ts b/frontend/src/components/home/useHomePhasePresentation.ts new file mode 100644 index 0000000..7b79e51 --- /dev/null +++ b/frontend/src/components/home/useHomePhasePresentation.ts @@ -0,0 +1,136 @@ +import { computed, onBeforeUnmount, ref, type ComputedRef, type Ref } from 'vue' + +import type { HomeDisplayCategory, HomePreviewPhase } from './homeLandingTypes' + +export function useHomePhasePresentation(params: { + previewPhase: Ref + displayCategories: ComputedRef + candidateCount: ComputedRef + publicStreamUrl: ComputedRef + showDate: ComputedRef + showStartsAt: ComputedRef + currentYear: ComputedRef + formatRange: (key: 'nomination' | 'voting' | 'review') => string + formatShowDate: () => string +}) { + const { + previewPhase, + displayCategories, + candidateCount, + publicStreamUrl, + showDate, + showStartsAt, + currentYear, + formatRange, + formatShowDate, + } = params + + const nominationPhase = computed(() => previewPhase.value === 'nomination') + const votingPhase = computed(() => previewPhase.value === 'voting') + const reviewPhase = computed(() => previewPhase.value === 'review') + const showPhase = computed(() => previewPhase.value === 'show') + const completedPhase = computed(() => previewPhase.value === 'completed') + const currentTimestamp = ref(Date.now()) + const ticker = setInterval(() => { + currentTimestamp.value = Date.now() + }, 1000) + onBeforeUnmount(() => clearInterval(ticker)) + const showCountdown = computed(() => true) + const showStartMs = computed(() => parseShowStartMs(showDate.value, showStartsAt.value)) + const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value) + const streamLocked = computed(() => !streamLive.value) + const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : reviewPhase.value ? 'Review & Auswertung' : 'Award Show Live') + const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche deine Favoriten und Highlight-Clips ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : reviewPhase.value ? 'Das Voting ist abgeschlossen. Das Team prüft Ergebnisse, Clips und finale Show-Momente.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.') + const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : reviewPhase.value ? `Review-Zeitraum · ${formatRange('review')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`) + const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : reviewPhase.value ? 'IN PRÜFUNG' : 'AKTIV') + const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : reviewPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;') + const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Nominieren & Clip' : votingPhase.value ? '★ Jetzt voten' : reviewPhase.value ? '✦ Auswertung läuft' : '● Zum Live-Stream') + const phasePrimaryDisabled = computed(() => reviewPhase.value || completedPhase.value) + const phasePrimaryActionStyle = computed(() => phasePrimaryDisabled.value + ? 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:#eee7f8;color:#9b8abf;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:none;cursor:not-allowed;' + : 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:0 10px 22px rgba(124,86,196,.32);cursor:pointer;') + const streamEyebrow = computed(() => 'Das grosse Finale') + const streamTitle = computed(() => 'Award-Show Countdown') + const streamMeta = computed(() => `Finale am ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`) + const streamLockedLabel = computed(() => completedPhase.value ? 'Award abgeschlossen' : 'Stream noch gesperrt') + const streamLockedTitle = computed(() => completedPhase.value ? 'Die Award-Show ist beendet' : 'Verfügbar, sobald die Show startet') + const statOneValue = computed(() => completedPhase.value ? 'DONE' : streamLive.value ? 'LIVE' : String(candidateCount.value)) + const statOneLabel = computed(() => completedPhase.value ? 'Jahr-Status' : showPhase.value ? 'Show-Status' : 'Kandidat:innen') + const statTwoValue = computed(() => String(displayCategories.value.length)) + const statThreeValue = computed(() => currentYear.value || '—') + const statThreeLabel = computed(() => 'Award-Jahr') + const categoryIntroText = computed(() => { + const count = displayCategories.value.length + const countLabel = count === 1 ? 'Eine Kategorie' : `${count || 'Alle'} Kategorien` + return `${countLabel}, eine Show, eine Szene. Stimm für deine Lieblings-VTuber ab und sei live dabei, wenn die Gewinner feststehen.` + }) + const timelineLineStyle = computed(() => nominationPhase.value + ? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#e2d6f4 0%,#e2d6f4 100%);border-radius:3px;z-index:0;' + : votingPhase.value + ? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 33.33%,#e2d6f4 33.33%,#e2d6f4 100%);border-radius:3px;z-index:0;' + : reviewPhase.value + ? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 66.66%,#e2d6f4 66.66%,#e2d6f4 100%);border-radius:3px;z-index:0;' + : 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 100%,#e2d6f4 100%,#e2d6f4 100%);border-radius:3px;z-index:0;') + const sectionTitle = computed(() => completedPhase.value ? 'Das Award-Jahr ist abgeschlossen ✦' : nominationPhase.value ? 'Jetzt Highlights und Favoriten einreichen ✦' : votingPhase.value ? 'Meine Favoriten unterstützen ⭐' : reviewPhase.value ? 'Das Voting wird gerade ausgewertet ✦' : 'Die Gewinner werden jetzt live gekürt ✦') + const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Lieblingsmomente ein und hilf mit, die stärksten Clips und spannendsten Namen in die Show zu bringen.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : reviewPhase.value ? 'Die Community hat abgestimmt. Jetzt prüft das Team Ergebnisse, Clips und finale Showeinspieler für die Award-Nacht.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.') + const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominieren & Clip einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : reviewPhase.value ? 'Auswertung läuft' : 'Zum Live-Stream') + const sectionActionHref = computed(() => showPhase.value ? publicStreamUrl.value : '#') + const sectionActionDisabled = computed(() => reviewPhase.value || completedPhase.value) + const sectionActionStyle = computed(() => sectionActionDisabled.value + ? 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:rgba(255,255,255,.72);color:#9b8abf;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:none;cursor:not-allowed;border:none;' + : nominationPhase.value + ? 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:#fff;color:#e855a5;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:0 14px 34px rgba(0,0,0,.22);' + : 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:#fff;color:#7a3fd0;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:0 14px 34px rgba(0,0,0,.22);') + + return { + nominationPhase, + votingPhase, + reviewPhase, + showPhase, + completedPhase, + showCountdown, + streamLive, + streamLocked, + phaseCardTitle, + phaseCardDescription, + phaseCardRange, + phaseStatusLabel, + phaseStatusStyle, + phasePrimaryLabel, + phasePrimaryDisabled, + phasePrimaryActionStyle, + streamEyebrow, + streamTitle, + streamMeta, + streamLockedLabel, + streamLockedTitle, + statOneValue, + statOneLabel, + statTwoValue, + statThreeValue, + statThreeLabel, + categoryIntroText, + timelineLineStyle, + sectionTitle, + sectionText, + sectionActionLabel, + sectionActionHref, + sectionActionDisabled, + sectionActionStyle, + } +} + +function parseShowStartMs(showDate: string, showStartsAt: string) { + if (!showDate) return Number.NaN + const time = normalizeTime(showStartsAt) + return new Date(`${showDate}T${time}`).getTime() +} + +function normalizeTime(value: string) { + if (!value) return '20:00:00' + return value.length === 5 ? `${value}:00` : value +} + +function formatTimeLabel(value: string) { + return normalizeTime(value).slice(0, 5) +} diff --git a/frontend/src/components/home/useHomeSocialPresentation.ts b/frontend/src/components/home/useHomeSocialPresentation.ts new file mode 100644 index 0000000..182d14b --- /dev/null +++ b/frontend/src/components/home/useHomeSocialPresentation.ts @@ -0,0 +1,65 @@ +import { computed, type ComputedRef } from 'vue' + +import { simpleIconForKey } from '../../lib/socialIcons' +import type { OverviewResponse } from '../../types/awards' + +export function useHomeSocialPresentation(siteContent: ComputedRef) { + const siteSocialLinks = computed(() => + (siteContent.value.socialLinks ?? []) + .filter((social) => social?.url && social.platform) + .map((social) => ({ + label: social.label || social.platform || 'Social Link', + platform: social.platform || 'link', + icon: social.icon || '', + url: social.url, + showOnHost: social.showOnHost ?? true, + showOnCommunity: social.showOnCommunity ?? true, + })), + ) + + const hostSocialLinks = computed(() => + siteSocialLinks.value.filter((social) => social.showOnHost).slice(0, 3), + ) + + const communitySocialLinks = computed(() => + siteSocialLinks.value.filter((social) => social.showOnCommunity), + ) + + const footerLinks = computed(() => + (siteContent.value.footerLinks ?? []).filter((link) => link?.label && link.url), + ) + + const privacyContentBlocks = computed(() => + (siteContent.value.privacyPolicyContent || '') + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter(Boolean), + ) + + function platformKey(value: string | null | undefined) { + return (value ?? 'link').trim().toLowerCase() + } + + function isUploadedSocialIcon(icon: string | null | undefined) { + return (icon ?? '').startsWith('data:image/') + } + + function socialSimpleIconPath(platform: string | null | undefined) { + return simpleIconForKey(platform)?.path ?? '' + } + + function socialSimpleIconColor(platform: string | null | undefined) { + return `#${simpleIconForKey(platform)?.hex ?? '5f44ad'}` + } + + return { + hostSocialLinks, + communitySocialLinks, + footerLinks, + privacyContentBlocks, + platformKey, + isUploadedSocialIcon, + socialSimpleIconPath, + socialSimpleIconColor, + } +} diff --git a/frontend/src/components/ui/NativeSelect.vue b/frontend/src/components/ui/NativeSelect.vue new file mode 100644 index 0000000..b25e8db --- /dev/null +++ b/frontend/src/components/ui/NativeSelect.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/lib/adminMetrics.ts b/frontend/src/lib/adminMetrics.ts index 2ea8186..73033d6 100644 --- a/frontend/src/lib/adminMetrics.ts +++ b/frontend/src/lib/adminMetrics.ts @@ -8,3 +8,7 @@ export function getMetricValue(metrics: AdminMetric[], labels: string[]) { export function getVoteMetricValue(metrics: AdminMetric[]) { return getMetricValue(metrics, ['Stimmen', 'Votes']) } + +export function getRiskMetricValue(metrics: AdminMetric[]) { + return getMetricValue(metrics, ['Risikohinweise', 'Risk Flags', 'Risiko']) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4debbf6..095a321 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,137 +1,14 @@ -import type { - AdminDashboardResponse, - AdminSeasonDetailResponse, - AdminSeasonListItem, - AuthSession, - CreateClipPayload, - CreateNominationPayload, - CreateVotePayload, - LoginPayload, - OverviewResponse, - SeasonCategoriesResponse, - UpdateSeasonPayload, - ApproveNominationPayload, - UpsertCandidatePayload, - UpsertCategoryPayload, - WinnerArchiveResponse, -} from '../types/awards' - -const API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:5084' -const AUTH_TOKEN_KEY = 'vtsa-session-token' - -function getAuthToken() { - if (typeof window === 'undefined') return null - return window.localStorage.getItem(AUTH_TOKEN_KEY) -} - -async function getJson(path: string): Promise { - const token = getAuthToken() - const response = await fetch(`${API_URL}${path}`, { - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : undefined, - }).catch(() => { - throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`) - }) - if (!response.ok) { - throw new Error(`API request failed for ${path}`) - } - return response.json() as Promise -} - -async function sendDelete(path: string): Promise { - const token = getAuthToken() - const response = await fetch(`${API_URL}${path}`, { - method: 'DELETE', - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }).catch(() => { - throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`) - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(error || `API request failed for ${path}`) - } - - return response.json() as Promise -} - -async function sendJson(path: string, method: 'POST' | 'PUT', body: unknown): Promise { - const token = getAuthToken() - const response = await fetch(`${API_URL}${path}`, { - method, - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify(body), - }).catch(() => { - throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`) - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(error || `API request failed for ${path}`) - } - - return response.json() as Promise -} +import { adminApi } from './api/adminApi' +import { authApi } from './api/authApi' +import { publicApi } from './api/publicApi' +import { systemApi } from './api/systemApi' export const api = { - getOverview: () => getJson('/api/public/overview'), - getSeasonCategories: (year: number) => - getJson(`/api/public/seasons/${year}/categories`), - getWinnerArchive: (year: number) => - getJson(`/api/public/seasons/${year}/winners`), - getAdminDashboard: () => getJson('/api/admin/dashboard'), - getAdminSeasons: () => getJson('/api/admin/seasons'), - getAdminSeasonDetail: (seasonId: number) => - getJson(`/api/admin/seasons/${seasonId}`), - getSession: () => getJson('/api/auth/session'), - login: (payload: LoginPayload) => sendJson('/api/auth/dev-login', 'POST', payload), - logout: () => sendJson<{ loggedOut: boolean }>('/api/auth/logout', 'POST', {}), - submitNomination: (payload: CreateNominationPayload) => - sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload), - submitVote: (payload: CreateVotePayload) => - sendJson<{ ballotId: number; entries: number }>('/api/public/votes', 'POST', payload), - submitClip: (payload: CreateClipPayload) => - sendJson<{ saved: boolean; clipId: number }>('/api/public/clips', 'POST', payload), - updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) => - sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload), - createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) => - sendJson<{ saved: boolean; categoryId: number }>(`/api/admin/seasons/${seasonId}/categories`, 'POST', payload), - updateAdminCategory: (categoryId: number, payload: UpsertCategoryPayload) => - sendJson<{ saved: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, 'PUT', payload), - createAdminCandidate: (seasonId: number, payload: UpsertCandidatePayload) => - sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload), - updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) => - sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, 'PUT', payload), - deleteAdminCandidate: (candidateId: number) => - sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`), - deleteAdminCategory: (categoryId: number) => - sendDelete<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`), - deleteAdminClip: (clipId: number) => - sendDelete<{ deleted: boolean; clipId: number }>(`/api/admin/clips/${clipId}`), - approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) => - sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>( - `/api/admin/nominations/${nominationId}/approve`, - 'POST', - payload, - ), - rejectAdminNomination: (nominationId: number) => - sendJson<{ saved: boolean; nominationId: number; rejected: boolean }>( - `/api/admin/nominations/${nominationId}/reject`, - 'POST', - {}, - ), - resolveRiskFlag: (riskFlagId: number, status = 'resolved') => - sendJson<{ saved: boolean; riskFlagId: number; status: string }>( - `/api/admin/risk-flags/${riskFlagId}/resolve`, - 'POST', - { status }, - ), + ...publicApi, + ...authApi, + ...adminApi, + ...systemApi, } -export { AUTH_TOKEN_KEY } +export { AUTH_TOKEN_KEY } from './http' +export { ApiRequestError } from './http' diff --git a/frontend/src/lib/api/adminApi.ts b/frontend/src/lib/api/adminApi.ts new file mode 100644 index 0000000..7cfd9d2 --- /dev/null +++ b/frontend/src/lib/api/adminApi.ts @@ -0,0 +1,149 @@ +import type { + AdminAuditEntriesResponse, + AdminAuditQueryOptions, + AdminDashboardResponse, + AdminOperationalSettingsResponse, + AdminRiskFlagsResponse, + AdminRiskQueryOptions, + AdminRiskRulesResponse, + AdminSeasonDetailResponse, + AdminSeasonListItem, + AdminSiteSettingsResponse, + ApproveNominationPayload, + BulkResolveRiskFlagsPayload, + CreateSeasonPayload, + RejectNominationPayload, + ResolveRiskFlagPayload, + SetAwardResultPayload, + UpdateClipStatusPayload, + UpdateOperationalSettingsPayload, + UpdateRiskRulesPayload, + UpdateSeasonPayload, + UpdateSiteSettingsPayload, + UpsertCandidatePayload, + UpsertCategoryPayload, +} from '../../types/awards' +import { requestJson } from '../http' +import { jsonRequest } from './requestOptions' + +function buildAdminAuditEntryParams(options: AdminAuditQueryOptions = {}) { + const params = new URLSearchParams({ limit: String(options.limit ?? 100) }) + const trimmedQuery = options.query?.trim() + const trimmedAdmin = options.admin?.trim() + const trimmedAction = options.action?.trim() + const trimmedEntityType = options.entityType?.trim() + + if (trimmedQuery) params.set('query', trimmedQuery) + if (trimmedAdmin) params.set('admin', trimmedAdmin) + if (trimmedAction) params.set('action', trimmedAction) + if (trimmedEntityType) params.set('entityType', trimmedEntityType) + if (options.from) params.set('from', options.from) + if (options.to) params.set('to', options.to) + if (options.cursor) params.set('cursor', options.cursor) + + return params +} + +export const adminApi = { + getAdminDashboard: () => requestJson('/api/admin/dashboard'), + getAdminAuditEntriesPage: (options: AdminAuditQueryOptions = {}) => + requestJson(`/api/admin/audit-entries?${buildAdminAuditEntryParams(options).toString()}`), + getAdminAuditEntries: (limit = 200, query = '') => { + const params = buildAdminAuditEntryParams({ limit, query }) + return requestJson(`/api/admin/audit-entries?${params.toString()}`) + .then((response) => response.items) + }, + getAdminRiskFlagsPage: (options: AdminRiskQueryOptions = {}) => { + const params = new URLSearchParams({ + limit: String(options.limit ?? 25), + offset: String(options.offset ?? 0), + status: options.status ?? 'open', + }) + if (options.severity) { + params.set('severity', options.severity) + } + const trimmedQuery = options.query?.trim() + if (trimmedQuery) { + params.set('query', trimmedQuery) + } + if (options.reviewedOnly) { + params.set('reviewedOnly', 'true') + } + + return requestJson(`/api/admin/risk-flags?${params.toString()}`) + }, + getAdminRiskFlags: (limit = 200, status = 'open', query = '') => + adminApi.getAdminRiskFlagsPage({ limit, status, query }).then((response) => response.items), + getAdminRiskRules: () => requestJson('/api/admin/risk-rules'), + updateAdminRiskRules: (payload: UpdateRiskRulesPayload) => + requestJson('/api/admin/risk-rules', jsonRequest('PUT', payload)), + getAdminSeasons: () => requestJson('/api/admin/seasons'), + getAdminSeasonDetail: (seasonId: number) => + requestJson(`/api/admin/seasons/${seasonId}`), + getAdminSiteSettings: () => requestJson('/api/admin/site-settings'), + getAdminOperationalSettings: () => + requestJson('/api/admin/operational-settings'), + createAdminSeason: (payload: CreateSeasonPayload) => + requestJson<{ saved: boolean; seasonId: number }>('/api/admin/seasons', jsonRequest('POST', payload)), + updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) => + requestJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, jsonRequest('PUT', payload)), + deleteAdminSeason: (seasonId: number) => + requestJson<{ deleted: boolean; seasonId: number; year: number }>(`/api/admin/seasons/${seasonId}`, { + method: 'DELETE', + }), + updateAdminSiteSettings: (payload: UpdateSiteSettingsPayload) => + requestJson<{ saved: boolean }>('/api/admin/site-settings', jsonRequest('PUT', payload)), + updateAdminOperationalSettings: (payload: UpdateOperationalSettingsPayload) => + requestJson<{ saved: boolean; demoLoginPasswordSet: boolean }>('/api/admin/operational-settings', jsonRequest('PUT', payload)), + createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) => + requestJson<{ saved: boolean; categoryId: number }>(`/api/admin/seasons/${seasonId}/categories`, jsonRequest('POST', payload)), + updateAdminCategory: (categoryId: number, payload: UpsertCategoryPayload) => + requestJson<{ saved: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, jsonRequest('PUT', payload)), + deleteAdminCategory: (categoryId: number) => + requestJson<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, { + method: 'DELETE', + }), + createAdminCandidate: (seasonId: number, payload: UpsertCandidatePayload) => + requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)), + updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) => + requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, jsonRequest('PUT', payload)), + deleteAdminCandidate: (candidateId: number) => + requestJson<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, { + method: 'DELETE', + }), + deleteAdminClip: (clipId: number) => + requestJson<{ deleted: boolean; clipId: number }>(`/api/admin/clips/${clipId}`, { + method: 'DELETE', + }), + updateAdminClipStatus: (clipId: number, payload: UpdateClipStatusPayload) => + requestJson<{ saved: boolean; clipId: number; status: string }>(`/api/admin/clips/${clipId}/status`, jsonRequest('POST', payload)), + setAdminResult: (seasonId: number, payload: SetAwardResultPayload) => + requestJson<{ saved: boolean; resultId: number; seasonId: number; categoryId: number; candidateId: number }>( + `/api/admin/seasons/${seasonId}/results`, + jsonRequest('POST', payload), + ), + deleteAdminResult: (resultId: number) => + requestJson<{ deleted: boolean; resultId: number }>(`/api/admin/results/${resultId}`, { + method: 'DELETE', + }), + approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) => + requestJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>( + `/api/admin/nominations/${nominationId}/approve`, + jsonRequest('POST', payload), + ), + rejectAdminNomination: (nominationId: number, payload: RejectNominationPayload) => + requestJson<{ saved: boolean; nominationId: number; rejected: boolean }>( + `/api/admin/nominations/${nominationId}/reject`, + jsonRequest('POST', payload), + ), + resolveRiskFlag: (riskFlagId: number, payload: ResolveRiskFlagPayload) => + requestJson<{ saved: boolean; riskFlagId: number; status: string }>( + `/api/admin/risk-flags/${riskFlagId}/resolve`, + jsonRequest('POST', payload), + ), + bulkResolveRiskFlags: (payload: BulkResolveRiskFlagsPayload) => + requestJson<{ saved: boolean; count: number; status: string }>( + '/api/admin/risk-flags/bulk-resolve', + jsonRequest('POST', payload), + ), +} diff --git a/frontend/src/lib/api/authApi.ts b/frontend/src/lib/api/authApi.ts new file mode 100644 index 0000000..9591305 --- /dev/null +++ b/frontend/src/lib/api/authApi.ts @@ -0,0 +1,26 @@ +import type { AuthSession, DemoLoginPayload, LoginPayload } from '../../types/awards' +import { requestJson } from '../http' +import { jsonRequest } from './requestOptions' + +export const authApi = { + getSession: () => requestJson('/api/auth/session'), + login: (payload: LoginPayload) => + requestJson('/api/auth/dev-login', jsonRequest('POST', payload)), + demoLogin: (payload: DemoLoginPayload) => + requestJson('/api/auth/demo-login', jsonRequest('POST', payload)), + logout: () => + requestJson<{ loggedOut: boolean }>('/api/auth/logout', jsonRequest('POST', {})), + deleteMyData: () => + requestJson<{ + deleted: boolean + twitchUserId: string + deletedVoteEntries: number + deletedBallots: number + deletedNominations: number + deletedClips: number + deletedRiskFlags: number + disabledSessions: number + }>('/api/auth/me/data', { + method: 'DELETE', + }), +} diff --git a/frontend/src/lib/api/publicApi.ts b/frontend/src/lib/api/publicApi.ts new file mode 100644 index 0000000..852662e --- /dev/null +++ b/frontend/src/lib/api/publicApi.ts @@ -0,0 +1,29 @@ +import type { + CreateClipPayload, + CreateNominationPayload, + CreateVotePayload, + OverviewResponse, + PublicSiteStatusResponse, + SeasonCategoriesResponse, + UserParticipationResponse, + WinnerArchiveResponse, +} from '../../types/awards' +import { requestJson } from '../http' +import { jsonRequest } from './requestOptions' + +export const publicApi = { + getOverview: () => requestJson('/api/public/overview'), + getSiteStatus: () => requestJson('/api/public/site-status'), + getSeasonCategories: (year: number) => + requestJson(`/api/public/seasons/${year}/categories`), + getWinnerArchive: (year: number) => + requestJson(`/api/public/seasons/${year}/winners`), + getMyParticipation: (year: number) => + requestJson(`/api/public/seasons/${year}/me`), + submitNomination: (payload: CreateNominationPayload) => + requestJson<{ saved: number; category: string }>('/api/public/nominations', jsonRequest('POST', payload)), + submitVote: (payload: CreateVotePayload) => + requestJson<{ ballotId: number; entries: number }>('/api/public/votes', jsonRequest('POST', payload)), + submitClip: (payload: CreateClipPayload) => + requestJson<{ saved: boolean; clipId: number }>('/api/public/clips', jsonRequest('POST', payload)), +} diff --git a/frontend/src/lib/api/requestOptions.ts b/frontend/src/lib/api/requestOptions.ts new file mode 100644 index 0000000..2a4fc6d --- /dev/null +++ b/frontend/src/lib/api/requestOptions.ts @@ -0,0 +1,7 @@ +export function jsonRequest(method: 'POST' | 'PUT', payload: unknown): RequestInit { + return { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } +} diff --git a/frontend/src/lib/api/systemApi.ts b/frontend/src/lib/api/systemApi.ts new file mode 100644 index 0000000..f3740a1 --- /dev/null +++ b/frontend/src/lib/api/systemApi.ts @@ -0,0 +1,6 @@ +import type { DatabaseHealthResponse } from '../../types/awards' +import { requestJson } from '../http' + +export const systemApi = { + getDatabaseHealth: () => requestJson('/api/health/database'), +} diff --git a/frontend/src/lib/http.ts b/frontend/src/lib/http.ts new file mode 100644 index 0000000..5e3db1d --- /dev/null +++ b/frontend/src/lib/http.ts @@ -0,0 +1,69 @@ +const API_URL = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '') +const API_LABEL = API_URL || 'same-origin /api' +export const AUTH_TOKEN_KEY = 'vtsa-session-token' + +export class ApiRequestError extends Error { + status: number | null + + constructor(message: string, status: number | null = null) { + super(message) + this.name = 'ApiRequestError' + this.status = status + } +} + +function getAuthToken() { + if (typeof window === 'undefined') return null + return window.localStorage.getItem(AUTH_TOKEN_KEY) +} + +function clearAuthToken() { + if (typeof window === 'undefined') return + window.localStorage.removeItem(AUTH_TOKEN_KEY) +} + +async function parseError(response: Response, path: string) { + const error = await response.text().catch(() => '') + const message = extractErrorMessage(error) || `API request failed for ${path}` + throw new ApiRequestError(message, response.status) +} + +function extractErrorMessage(error: string) { + if (!error.trim()) return '' + + try { + const parsed = JSON.parse(error) as { message?: unknown; title?: unknown; detail?: unknown } + const message = parsed.message ?? parsed.detail ?? parsed.title + return typeof message === 'string' ? message : error + } catch { + return error + } +} + +export async function requestJson( + path: string, + options: RequestInit = {}, +): Promise { + const token = getAuthToken() + const headers = new Headers(options.headers) + + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + const response = await fetch(`${API_URL}${path}`, { + ...options, + headers, + }).catch(() => { + throw new ApiRequestError(`API nicht erreichbar (${API_LABEL}). Bitte Backend starten.`) + }) + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + clearAuthToken() + } + await parseError(response, path) + } + + return response.json() as Promise +} diff --git a/frontend/src/lib/siteStatus.ts b/frontend/src/lib/siteStatus.ts new file mode 100644 index 0000000..7db88ce --- /dev/null +++ b/frontend/src/lib/siteStatus.ts @@ -0,0 +1,16 @@ +import { api } from './api' +import type { PublicSiteStatusResponse } from '../types/awards' + +let siteStatusPromise: Promise | null = null + +export function clearSiteStatusCache() { + siteStatusPromise = null +} + +export function loadSiteStatus(force = false) { + if (force || !siteStatusPromise) { + siteStatusPromise = api.getSiteStatus().catch(() => null) + } + + return siteStatusPromise +} diff --git a/frontend/src/lib/socialIcons.ts b/frontend/src/lib/socialIcons.ts new file mode 100644 index 0000000..fb33f6c --- /dev/null +++ b/frontend/src/lib/socialIcons.ts @@ -0,0 +1,190 @@ +import { + siArtstation, + siBilibili, + siBluesky, + siBuymeacoffee, + siCarrd, + siDiscord, + siDeviantart, + siFacebook, + siFandom, + siFiverr, + siGithub, + siGuilded, + siInstagram, + siKick, + siKofi, + siLinktree, + siMastodon, + siMatrix, + siMedium, + siNiconico, + siOnlyfans, + siPatreon, + siPicartodottv, + siPinterest, + siPixiv, + siReddit, + siSnapchat, + siSoundcloud, + siSpotify, + siSteam, + siSubstack, + siTelegram, + siThreads, + siTiktok, + siTumblr, + siTwitch, + siVimeo, + siWhatsapp, + siX, + siYoutube, + type SimpleIcon, +} from 'simple-icons' + +export type SocialIconOption = { + key: string + label: string +} + +export type SocialIconOptionGroup = { + label: string + options: SocialIconOption[] +} + +const SOCIAL_ICONS: Record = { + artstation: siArtstation, + bilibili: siBilibili, + bluesky: siBluesky, + buymeacoffee: siBuymeacoffee, + carrd: siCarrd, + discord: siDiscord, + deviantart: siDeviantart, + facebook: siFacebook, + fandom: siFandom, + fiverr: siFiverr, + github: siGithub, + guilded: siGuilded, + instagram: siInstagram, + kick: siKick, + kofi: siKofi, + linktree: siLinktree, + mastodon: siMastodon, + matrix: siMatrix, + medium: siMedium, + niconico: siNiconico, + onlyfans: siOnlyfans, + patreon: siPatreon, + picarto: siPicartodottv, + picartotv: siPicartodottv, + pinterest: siPinterest, + pixiv: siPixiv, + reddit: siReddit, + snapchat: siSnapchat, + soundcloud: siSoundcloud, + spotify: siSpotify, + steam: siSteam, + substack: siSubstack, + telegram: siTelegram, + threads: siThreads, + tiktok: siTiktok, + tumblr: siTumblr, + twitch: siTwitch, + twitter: siX, + vimeo: siVimeo, + whatsapp: siWhatsapp, + x: siX, + youtube: siYoutube, +} + +export const SOCIAL_ICON_OPTION_GROUPS: SocialIconOptionGroup[] = [ + { + label: 'Streaming & Video', + options: [ + { key: 'twitch', label: 'Twitch' }, + { key: 'youtube', label: 'YouTube' }, + { key: 'kick', label: 'Kick' }, + { key: 'tiktok', label: 'TikTok' }, + { key: 'bilibili', label: 'Bilibili' }, + { key: 'niconico', label: 'Niconico' }, + { key: 'picarto', label: 'Picarto.TV' }, + { key: 'vimeo', label: 'Vimeo' }, + ], + }, + { + label: 'Social & Community', + options: [ + { key: 'x', label: 'X / Twitter' }, + { key: 'instagram', label: 'Instagram' }, + { key: 'bluesky', label: 'Bluesky' }, + { key: 'threads', label: 'Threads' }, + { key: 'mastodon', label: 'Mastodon' }, + { key: 'discord', label: 'Discord' }, + { key: 'guilded', label: 'Guilded' }, + { key: 'matrix', label: 'Matrix' }, + { key: 'telegram', label: 'Telegram' }, + { key: 'whatsapp', label: 'WhatsApp' }, + { key: 'reddit', label: 'Reddit' }, + { key: 'facebook', label: 'Facebook' }, + { key: 'snapchat', label: 'Snapchat' }, + ], + }, + { + label: 'Creator & Support', + options: [ + { key: 'patreon', label: 'Patreon' }, + { key: 'kofi', label: 'Ko-fi' }, + { key: 'buymeacoffee', label: 'Buy Me a Coffee' }, + { key: 'linktree', label: 'Linktree' }, + { key: 'carrd', label: 'Carrd' }, + { key: 'substack', label: 'Substack' }, + { key: 'medium', label: 'Medium' }, + { key: 'github', label: 'GitHub' }, + { key: 'fiverr', label: 'Fiverr' }, + { key: 'onlyfans', label: 'OnlyFans' }, + ], + }, + { + label: 'Art, Musik & Portfolio', + options: [ + { key: 'pixiv', label: 'Pixiv' }, + { key: 'deviantart', label: 'DeviantArt' }, + { key: 'artstation', label: 'ArtStation' }, + { key: 'pinterest', label: 'Pinterest' }, + { key: 'tumblr', label: 'Tumblr' }, + { key: 'fandom', label: 'Fandom' }, + { key: 'spotify', label: 'Spotify' }, + { key: 'soundcloud', label: 'SoundCloud' }, + { key: 'steam', label: 'Steam' }, + ], + }, + { + label: 'Fallback', + options: [ + { key: 'website', label: 'Website / Fallback' }, + ], + }, +] + +export const SOCIAL_ICON_OPTIONS: SocialIconOption[] = SOCIAL_ICON_OPTION_GROUPS.flatMap((group) => group.options) + +export function normalizeSocialIconKey(value: string | null | undefined) { + return (value ?? 'website').trim().toLowerCase() +} + +export function socialIconOptionForKey(value: string | null | undefined) { + const normalized = normalizeSocialIconKey(value) + return SOCIAL_ICON_OPTIONS.find((option) => option.key === normalized) ?? null +} + +export function socialIconOptionForValue(value: string | null | undefined) { + const normalized = normalizeSocialIconKey(value) + return SOCIAL_ICON_OPTIONS.find((option) => + option.key === normalized || + option.label.trim().toLowerCase() === normalized, + ) ?? null +} + +export function simpleIconForKey(value: string | null | undefined) { + return SOCIAL_ICONS[normalizeSocialIconKey(value)] ?? null +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 6797e4a..c58476f 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -1,25 +1,11 @@ import { createRouter, createWebHistory } from 'vue-router' +import { loadSiteStatus } from './lib/siteStatus' import { useAuthStore } from './stores/auth' import { useAwardsStore } from './stores/awards' +import { routes } from './router/routes' -import AdminCandidatesView from './views/admin/AdminCandidatesView.vue' -import AdminAnalyticsView from './views/admin/AdminAnalyticsView.vue' -import AdminCategoriesView from './views/admin/AdminCategoriesView.vue' -import AdminClipsView from './views/admin/AdminClipsView.vue' -import AdminDashboardView from './views/admin/AdminDashboardView.vue' -import AdminLayoutView from './views/admin/AdminLayoutView.vue' -import AdminNominationsView from './views/admin/AdminNominationsView.vue' -import AdminReviewsView from './views/admin/AdminReviewsView.vue' -import AdminRiskView from './views/admin/AdminRiskView.vue' -import AdminSeasonsView from './views/admin/AdminSeasonsView.vue' -import AdminSettingsView from './views/admin/AdminSettingsView.vue' -import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue' -import AdminVotingView from './views/admin/AdminVotingView.vue' -import ClipSubmissionView from './views/ClipSubmissionView.vue' -import HomeView from './views/HomeView.vue' -import NominationsView from './views/NominationsView.vue' -import VotingView from './views/VotingView.vue' -import WinnersView from './views/WinnersView.vue' +const DEMO_GATE_HARD_DISABLED = import.meta.env.VITE_DEMO_GATE_ENABLED === 'false' +const contentAdminRoutes = new Set(['admin-content', 'admin-settings']) const router = createRouter({ history: createWebHistory(), @@ -34,161 +20,7 @@ const router = createRouter({ return { top: 0 } }, - routes: [ - { - path: '/', - name: 'home', - component: HomeView, - }, - { - path: '/nominations', - name: 'nominations', - component: NominationsView, - meta: { - requiresAuth: true, - keepAlive: true, - phase: 'nomination', - }, - }, - { - path: '/voting', - name: 'voting', - component: VotingView, - meta: { - requiresAuth: true, - keepAlive: true, - phase: 'voting', - }, - }, - { - path: '/clips', - name: 'clips', - component: ClipSubmissionView, - meta: { - requiresAuth: true, - keepAlive: true, - phase: 'nomination', - }, - }, - { - path: '/winners', - name: 'winners', - component: WinnersView, - }, - { - path: '/admin', - component: AdminLayoutView, - meta: { - requiresAdmin: true, - }, - children: [ - { - path: '', - redirect: { name: 'admin-dashboard' }, - }, - { - path: 'dashboard', - name: 'admin-dashboard', - component: AdminDashboardView, - meta: { - keepAlive: true, - }, - }, - { - path: 'seasons', - redirect: { name: 'admin-years' }, - }, - { - path: 'years', - name: 'admin-years', - component: AdminSeasonsView, - meta: { - keepAlive: true, - }, - }, - { - path: 'nominations', - name: 'admin-nominations', - component: AdminNominationsView, - meta: { - keepAlive: true, - }, - }, - { - path: 'voting', - name: 'admin-voting', - component: AdminVotingView, - meta: { - keepAlive: true, - }, - }, - { - path: 'categories', - name: 'admin-categories', - component: AdminCategoriesView, - meta: { - keepAlive: true, - }, - }, - { - path: 'candidates', - name: 'admin-candidates', - component: AdminCandidatesView, - meta: { - keepAlive: true, - }, - }, - { - path: 'clips', - name: 'admin-clips', - component: AdminClipsView, - meta: { - keepAlive: true, - }, - }, - { - path: 'reviews', - name: 'admin-reviews', - component: AdminReviewsView, - meta: { - keepAlive: true, - }, - }, - { - path: 'risk', - name: 'admin-risk', - component: AdminRiskView, - meta: { - keepAlive: true, - }, - }, - { - path: 'users-logs', - name: 'admin-users-logs', - component: AdminUsersLogsView, - meta: { - keepAlive: true, - }, - }, - { - path: 'analytics', - name: 'admin-analytics', - component: AdminAnalyticsView, - meta: { - keepAlive: true, - }, - }, - { - path: 'settings', - name: 'admin-settings', - component: AdminSettingsView, - meta: { - keepAlive: true, - }, - }, - ], - }, - ], + routes, }) router.beforeEach(async (to) => { @@ -198,12 +30,53 @@ router.beforeEach(async (to) => { await authStore.hydrate() } - if (to.meta.requiresAuth && !authStore.isLoggedIn) { + const isLoginRoute = to.name === 'login' + const isMaintenanceRoute = to.name === 'maintenance' + const isAdminRoute = to.path.startsWith('/admin') + const isMaintenancePreview = isMaintenanceRoute && to.query.preview === 'true' + const siteStatus = await loadSiteStatus() + const demoGateEnabled = !DEMO_GATE_HARD_DISABLED && siteStatus?.demoLoginEnabled !== false + + if (isMaintenancePreview && !authStore.isAdmin) { + return { name: 'login', query: { redirect: to.fullPath } } + } + + if (siteStatus?.maintenanceModeEnabled && !isMaintenanceRoute && !isLoginRoute && !isAdminRoute) { + return { name: 'maintenance', query: { from: to.fullPath } } + } + + if (isMaintenanceRoute && !isMaintenancePreview && siteStatus && !siteStatus.maintenanceModeEnabled) { return { name: 'home' } } + if (demoGateEnabled && !isLoginRoute && !isMaintenanceRoute && !authStore.isLoggedIn) { + return { name: 'login', query: { redirect: to.fullPath } } + } + + if (demoGateEnabled && isLoginRoute && authStore.isLoggedIn) { + const redirect = Array.isArray(to.query.redirect) + ? to.query.redirect[0] + : to.query.redirect + + return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('/login') + ? redirect + : { name: 'home' } + } + + if (to.meta.requiresAuth && !authStore.isLoggedIn) { + return { name: 'login', query: { redirect: to.fullPath } } + } + if (to.meta.requiresAdmin && !authStore.isAdmin) { - return { name: 'home' } + return { name: 'login', query: { redirect: to.fullPath } } + } + + if ( + to.path.startsWith('/admin') && + authStore.session?.role === 'content_admin' && + !contentAdminRoutes.has(String(to.name ?? '')) + ) { + return { name: 'admin-content' } } // Phasen-Gate: eine Aktion ist nur in ihrem aktiven Zeitfenster erreichbar. diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts new file mode 100644 index 0000000..c271ddf --- /dev/null +++ b/frontend/src/router/routes.ts @@ -0,0 +1,140 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const adminRoutes: RouteRecordRaw[] = [ + { + path: '', + redirect: { name: 'admin-dashboard' }, + }, + { + path: 'dashboard', + name: 'admin-dashboard', + component: () => import('../views/admin/AdminDashboardView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'seasons', + redirect: { name: 'admin-years' }, + }, + { + path: 'years', + name: 'admin-years', + component: () => import('../views/admin/AdminSeasonsView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'nominations', + name: 'admin-nominations', + component: () => import('../views/admin/AdminNominationsView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'categories', + name: 'admin-categories', + component: () => import('../views/admin/AdminCategoriesView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'candidates', + name: 'admin-candidates', + component: () => import('../views/admin/AdminCandidatesView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'clips', + name: 'admin-clips', + component: () => import('../views/admin/AdminClipsView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'reviews', + name: 'admin-reviews', + redirect: (to) => ({ + name: 'admin-nominations', + query: { ...to.query, review: '1' }, + }), + }, + { + path: 'risk', + name: 'admin-risk', + component: () => import('../views/admin/AdminRiskView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'users-logs', + name: 'admin-users-logs', + component: () => import('../views/admin/AdminUsersLogsView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'analytics', + name: 'admin-analytics', + component: () => import('../views/admin/AdminAnalyticsView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'winners', + name: 'admin-winners', + component: () => import('../views/admin/AdminWinnersView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'content', + name: 'admin-content', + component: () => import('../views/admin/AdminContentView.vue'), + meta: { keepAlive: true }, + }, + { + path: 'settings', + name: 'admin-settings', + component: () => import('../views/admin/AdminSettingsView.vue'), + meta: { keepAlive: true }, + }, +] + +export const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'home', + component: () => import('../views/HomeView.vue'), + }, + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue'), + meta: { + bareShell: true, + }, + }, + { + path: '/status/network-error', + name: 'network-error', + component: () => import('../views/NetworkErrorView.vue'), + meta: { + bareShell: true, + }, + }, + { + path: '/maintenance', + name: 'maintenance', + component: () => import('../views/MaintenanceView.vue'), + meta: { + bareShell: true, + }, + }, + { + path: '/admin', + component: () => import('../views/admin/AdminLayoutView.vue'), + meta: { + requiresAdmin: true, + }, + children: adminRoutes, + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('../views/NotFoundView.vue'), + meta: { + bareShell: true, + }, + }, +] diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index d6fd3ac..9ddf4ca 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,7 +1,11 @@ import { defineStore } from 'pinia' import { AUTH_TOKEN_KEY, api } from '../lib/api' -import type { AuthSession, LoginPayload } from '../types/awards' +import { ApiRequestError } from '../lib/http' +import type { AuthSession, DemoLoginPayload, LoginPayload } from '../types/awards' + +const adminAccessRoles = new Set(['content_admin', 'admin', 'owner']) +const adminWorkspaceRoles = new Set(['admin', 'owner']) function readStoredToken() { if (typeof window === 'undefined') return null @@ -26,7 +30,12 @@ export const useAuthStore = defineStore('auth', { }), getters: { isLoggedIn: (state) => Boolean(state.session), - isAdmin: (state) => state.session?.role === 'admin', + isAdmin: (state) => adminAccessRoles.has(state.session?.role ?? ''), + canAccessAdmin: (state) => adminAccessRoles.has(state.session?.role ?? ''), + canManageContent: (state) => adminAccessRoles.has(state.session?.role ?? ''), + canManageAdminWorkspace: (state) => adminWorkspaceRoles.has(state.session?.role ?? ''), + canManageOperationalSettings: (state) => state.session?.role === 'owner', + isOwner: (state) => state.session?.role === 'owner', }, actions: { async hydrate() { @@ -37,9 +46,11 @@ export const useAuthStore = defineStore('auth', { try { this.session = await api.getSession() - } catch { + } catch (error) { this.session = null - writeStoredToken(null) + if (error instanceof ApiRequestError && (error.status === 401 || error.status === 403)) { + writeStoredToken(null) + } } finally { this.hydrated = true } @@ -54,6 +65,16 @@ export const useAuthStore = defineStore('auth', { this.loading = false } }, + async demoLogin(payload: DemoLoginPayload) { + this.loading = true + try { + const session = await api.demoLogin(payload) + this.session = session + writeStoredToken(session.sessionToken) + } finally { + this.loading = false + } + }, async logout() { this.loading = true try { @@ -64,5 +85,18 @@ export const useAuthStore = defineStore('auth', { this.loading = false } }, + async deleteMyData() { + this.loading = true + try { + const result = await api.deleteMyData() + this.session = null + writeStoredToken(null) + return result + } finally { + this.loading = false + } + }, }, }) + +export type AuthStore = ReturnType diff --git a/frontend/src/stores/awards.ts b/frontend/src/stores/awards.ts index fe8d7cc..90bd5c2 100644 --- a/frontend/src/stores/awards.ts +++ b/frontend/src/stores/awards.ts @@ -1,257 +1,59 @@ import { defineStore } from 'pinia' import { api } from '../lib/api' +import { + classifyPublicLoadError, + createAwardsState, + createEmptyAdminDashboard, + createEmptyAdminRiskFlagsResponse, + createEmptyAdminSeasonDetail, + createEmptyAdminSiteSettings, + createEmptyArchive, + createEmptyDatabaseHealth, + normalizeSeasonDetail, +} from './awards/defaults' import type { - AdminDashboardResponse, - AdminSeasonDetailResponse, ApproveNominationPayload, - AdminSeasonListItem, + CreateSeasonPayload, CreateClipPayload, CreateNominationPayload, CreateVotePayload, - OverviewResponse, - SeasonCategoriesResponse, + RejectNominationPayload, + ResolveRiskFlagPayload, + SetAwardResultPayload, + AdminAuditQueryOptions, + AdminRiskQueryOptions, UpdateSeasonPayload, + UpdateClipStatusPayload, + UpdateSiteSettingsPayload, UpsertCandidatePayload, UpsertCategoryPayload, - WinnerArchiveResponse, } from '../types/awards' -const fallbackOverview: OverviewResponse = { - seasonId: 1, - year: 2026, - title: 'VTuber Star Awards 2026', - showDate: '2026-01-24', - currentPhase: 'Community Voting', - isCommunityOnly: true, - loginProvider: 'Twitch', - timeline: [ - { key: 'nomination', title: 'Nominierung', startsAt: '2026-05-01', endsAt: '2026-05-31', state: 'done' }, - { key: 'voting', title: 'Voting', startsAt: '2026-06-01', endsAt: '2026-06-30', state: 'active' }, - { key: 'review', title: 'Auswertung', startsAt: '2026-07-01', endsAt: '2026-07-10', state: 'upcoming' }, - { key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' }, - ], - featuredCategories: [ - { id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die VTuberin oder der VTuber, der dieses Jahr einfach alle verzaubert hat.', maxNomineesPerUser: 3 }, - { id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Das Event, das die Community zum Beben gebracht hat – Konzert, Watchalong oder Mega-Stream.', maxNomineesPerUser: 3 }, - { id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der eine Clip, den du seit Monaten in jeden Chat spammst.', maxNomineesPerUser: 3 }, - ], - winnersPreview: [ - { year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' }, - { year: 2025, category: 'Bestes Live Event', winnerName: 'Kurainu 3D Live', winnerSlug: '@kurainu' }, - { year: 2024, category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' }, - ], - faq: [ - { question: 'Wer darf mitmachen?', answer: 'Jede:r mit einem Twitch-Account. Einmal einloggen genügt – kein extra Konto, kein Papierkram.' }, - { question: 'Wie werden die Gewinner bestimmt?', answer: 'Komplett durch eure Stimmen. Die Community entscheidet, wer auf die Bühne darf – kein Jury-Geheimnis.' }, - { question: 'Kann ich meine Wahl noch ändern?', answer: 'Klar! Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen jederzeit anpassen.' }, - { question: 'Wer kuratiert die Kategorien?', answer: 'Das Jayuhime-Team stellt die Kategorien jedes Jahr frisch zusammen, passend zur Community.' }, - ], -} - -const fallbackCategories: SeasonCategoriesResponse = { - seasonId: 1, - year: 2026, - categories: [ - { - id: 1, - name: 'VTuber des Jahres', - groupName: 'Main Awards', - description: 'Die Hauptkategorie – für die prägendste Creator-Präsenz des ganzen Jahres.', - maxNomineesPerUser: 3, - candidates: [ - { id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/HoshimiHighlight' }, - { id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu' }, - { id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/ShiroMoment' }, - ], - }, - { - id: 2, - name: 'Bestes Live Event', - groupName: 'Performance', - description: 'Konzerte, Sonderformate und große Community-Shows, die in Erinnerung bleiben.', - maxNomineesPerUser: 3, - candidates: [ - { id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu3d' }, - { id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube', clipUrl: 'https://www.youtube.com/watch?v=aoisakura' }, - ], - }, - ], -} - -const fallbackArchive: WinnerArchiveResponse = { - year: 2025, - items: [ - { category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' }, - { category: 'Bestes Live Event', winnerName: 'Kurainu 3D Live', winnerSlug: '@kurainu' }, - { category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' }, - ], -} - -const fallbackAdmin: AdminDashboardResponse = { - metrics: [ - { label: 'Nominierungen', value: 12341, note: '+12.4% vs. gestern' }, - { label: 'Stimmen', value: 587231, note: '+8.7% vs. gestern' }, - { label: 'Kategorien', value: 28, note: 'aktiv im Jahr 2026' }, - { label: 'Reviews offen', value: 47, note: '14 neu' }, - ], - activities: [ - { label: 'Neue Nominierung in Bester neuer VTuber', age: 'vor 2 Min.' }, - { label: 'Clip-Dublette erkannt in Clip des Jahres', age: 'vor 7 Min.' }, - { label: 'Alias-Zusammenfuehrung fuer Hoshimi Miyu geprueft', age: 'vor 18 Min.' }, - ], - topCategories: [ - { category: 'VTuber des Jahres', votes: 186321 }, - { category: 'Bestes Live Event', votes: 132550 }, - { category: 'Clip des Jahres', votes: 98210 }, - ], - riskFlags: [ - { - id: 1, - source: 'vote', - type: 'rapid_vote_updates', - severity: 'high', - status: 'open', - summary: 'Mehrere Voting-Aenderungen in kurzer Zeit erkannt.', - twitchUserId: 'demo_user', - createdFromIp: '127.0.0.1', - createdAt: '2026-06-17T08:40:00Z', - metadataJson: '{"recentVoteSubmissions":3}', - }, - ], - auditEntries: [ - { - id: 1, - adminTwitchUserId: 'jayuhime_admin', - actionType: 'category.update', - entityType: 'category', - entityId: '1', - summary: 'Kategorie VTuber des Jahres wurde aktualisiert.', - createdAt: '2026-06-17T08:32:00Z', - }, - ], -} - -const fallbackAdminSeasons: AdminSeasonListItem[] = [ - { id: 1, year: 2026, name: 'VTuber Star Awards 2026', currentPhase: 'Community Voting', isCurrent: true, categoryCount: 4 }, - { id: 2, year: 2025, name: 'VTuber Star Awards 2025', currentPhase: 'Archiviert', isCurrent: false, categoryCount: 3 }, -] - -const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = { - id: 1, - year: 2026, - name: 'VTuber Star Awards 2026', - currentPhase: 'Community Voting', - isCurrent: true, - categories: [ - { - id: 1, - groupName: 'Hauptpreise', - name: 'VTuber des Jahres', - slug: 'vtuber-des-jahres', - description: 'Die groesste Auszeichnung des Jahres.', - sortOrder: 1, - maxNomineesPerUser: 3, - candidateCount: 3, - }, - { - id: 2, - groupName: 'Performance', - name: 'Bestes Live Event', - slug: 'bestes-live-event', - description: 'Events, Konzerte und 3D-Shows.', - sortOrder: 2, - maxNomineesPerUser: 3, - candidateCount: 2, - }, - ], - candidates: [ - { id: 1, categoryId: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' }, - { id: 2, categoryId: 1, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' }, - ], - pendingNominations: [ - { - id: 1, - categoryId: 1, - categoryName: 'VTuber des Jahres', - submittedByTwitchId: 'demo_user', - candidateText: 'Session Nominee', - createdAt: '2026-06-17T08:00:00Z', - }, - ], - clipSubmissions: [ - { - id: 1, - categoryId: 1, - submittedByTwitchId: 'demo_user', - clipUrl: 'https://clips.twitch.tv/DemoClip', - title: 'Epischer Clutch im Finale', - creator: 'Hoshimi Miyu', - platform: 'Twitch', - status: 'pending', - createdAt: '2026-06-17T09:10:00Z', - }, - ], -} - -const emptyAdmin: AdminDashboardResponse = { - metrics: [], - activities: [], - topCategories: [], - riskFlags: [], - auditEntries: [], -} - -const emptyAdminSeasons: AdminSeasonListItem[] = [] - -const emptyAdminSeasonDetail: AdminSeasonDetailResponse = { - id: 0, - year: 0, - name: '', - currentPhase: '', - isCurrent: false, - categories: [], - candidates: [], - pendingNominations: [], - clipSubmissions: [], -} - -/** - * Guarantee the array fields exist even if a (possibly older) backend omits them, - * so views can safely read `.length`/`.filter` without crashing the render. - */ -function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse { - return { - ...detail, - categories: detail.categories ?? [], - candidates: detail.candidates ?? [], - pendingNominations: detail.pendingNominations ?? [], - clipSubmissions: detail.clipSubmissions ?? [], - } +interface AdminSeasonRefreshOptions { + reloadAdmin?: boolean + reloadHome?: boolean } export const useAwardsStore = defineStore('awards', { - state: () => ({ - overview: fallbackOverview as OverviewResponse, - categories: fallbackCategories as SeasonCategoriesResponse, - archive: fallbackArchive as WinnerArchiveResponse, - admin: fallbackAdmin as AdminDashboardResponse, - adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[], - adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse, - adminSelectedSeasonId: fallbackAdminSeasonDetail.id as number | null, - loading: false, - apiMode: 'fallback' as 'api' | 'fallback', - }), + state: createAwardsState, actions: { async loadHomeData() { this.loading = true + this.lastPublicError = null + this.lastPublicErrorKind = null try { this.overview = await api.getOverview() this.categories = await api.getSeasonCategories(this.overview.year) this.archive = await api.getWinnerArchive(this.overview.winnersPreview[0]?.year ?? this.overview.year - 1) this.apiMode = 'api' - } catch { + } catch (error) { this.apiMode = 'fallback' + const { message, kind } = classifyPublicLoadError(error) + if (kind) { + this.lastPublicError = message + this.lastPublicErrorKind = kind + } } finally { this.loading = false } @@ -261,35 +63,71 @@ export const useAwardsStore = defineStore('awards', { this.archive = await api.getWinnerArchive(year) this.apiMode = 'api' } catch { - this.archive = { ...fallbackArchive, year } + this.archive = createEmptyArchive(year) } }, async loadAdmin() { try { - this.admin = await api.getAdminDashboard() - this.adminSeasons = await api.getAdminSeasons() + const [admin, adminSeasons, adminSiteSettings, databaseHealth] = await Promise.all([ + api.getAdminDashboard(), + api.getAdminSeasons(), + api.getAdminSiteSettings(), + api.getDatabaseHealth(), + ]) + + this.admin = admin + this.adminSeasons = adminSeasons + this.adminSiteSettings = adminSiteSettings + this.databaseHealth = databaseHealth + if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) { this.adminSelectedSeasonId = this.adminSeasons[0]?.id ?? null } if (this.adminSelectedSeasonId) { this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(this.adminSelectedSeasonId)) + } else { + this.adminSeasonDetail = createEmptyAdminSeasonDetail() } this.apiMode = 'api' } catch { - this.admin = emptyAdmin - this.adminSeasons = emptyAdminSeasons - this.adminSeasonDetail = emptyAdminSeasonDetail + this.admin = createEmptyAdminDashboard() + this.adminSeasons = [] + this.adminSeasonDetail = createEmptyAdminSeasonDetail() + this.adminSiteSettings = createEmptyAdminSiteSettings() + this.adminRiskHistory = [] + this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse() + this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse() + this.databaseHealth = createEmptyDatabaseHealth() this.adminSelectedSeasonId = null } }, + async loadAdminContentWorkspace() { + try { + const [adminSiteSettings, databaseHealth] = await Promise.all([ + api.getAdminSiteSettings(), + api.getDatabaseHealth(), + ]) + + this.adminSiteSettings = adminSiteSettings + this.databaseHealth = databaseHealth + this.apiMode = 'api' + } catch { + this.adminSiteSettings = createEmptyAdminSiteSettings() + this.databaseHealth = createEmptyDatabaseHealth() + } + }, + async loadDatabaseHealth() { + this.databaseHealth = await api.getDatabaseHealth() + return this.databaseHealth + }, async loadAdminSeasonDetail(seasonId: number) { try { this.adminSelectedSeasonId = seasonId this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(seasonId)) this.apiMode = 'api' } catch { - this.adminSeasonDetail = emptyAdminSeasonDetail + this.adminSeasonDetail = createEmptyAdminSeasonDetail() } }, async initializeAdminWorkspace() { @@ -298,6 +136,63 @@ export const useAwardsStore = defineStore('awards', { await this.loadAdminSeasonDetail(this.adminSelectedSeasonId) } }, + async refreshAdminSeasonWorkspace(seasonId: number, options: AdminSeasonRefreshOptions = {}) { + this.adminSelectedSeasonId = seasonId + if (options.reloadAdmin) { + await this.loadAdmin() + } else { + await this.loadAdminSeasonDetail(seasonId) + } + + if (options.reloadHome) { + await this.loadHomeData() + } + }, + async refreshAfterSeasonListMutation(options: Pick = {}) { + await this.loadAdmin() + if (options.reloadHome) { + await this.loadHomeData() + } + }, + async loadAdminAuditEntries(limit = 200, query = '') { + const auditEntries = await api.getAdminAuditEntries(limit, query) + this.admin = { ...this.admin, auditEntries } + return auditEntries + }, + async loadAdminAuditEntriesPage(options: AdminAuditQueryOptions = {}, append = false) { + const response = await api.getAdminAuditEntriesPage(options) + this.admin = { + ...this.admin, + auditEntries: append ? [...this.admin.auditEntries, ...response.items] : response.items, + } + return response + }, + async loadAdminRiskFlags(limit = 200, status = 'open', query = '') { + const response = await this.loadAdminRiskFlagsPage({ limit, status, query }) + return response.items + }, + async loadAdminRiskFlagsPage(options: AdminRiskQueryOptions = {}) { + const response = await api.getAdminRiskFlagsPage(options) + this.adminRiskFlagsPage = response + if ((options.status ?? 'open') === 'open' && !options.reviewedOnly) { + this.admin = { ...this.admin, riskFlags: response.items } + } + return response + }, + async loadAdminRiskHistory(limit = 80, query = '') { + const response = await this.loadAdminRiskHistoryPage({ limit, query }) + return response.items + }, + async loadAdminRiskHistoryPage(options: AdminRiskQueryOptions = {}) { + const response = await api.getAdminRiskFlagsPage({ + ...options, + status: options.status ?? 'all', + reviewedOnly: true, + }) + this.adminRiskHistoryPage = response + this.adminRiskHistory = response.items + return response + }, setAdminSeason(seasonId: number) { this.adminSelectedSeasonId = seasonId }, @@ -312,60 +207,122 @@ export const useAwardsStore = defineStore('awards', { }, async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) { const result = await api.updateAdminSeason(seasonId, payload) - await this.loadAdmin() + await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true }) + return result + }, + async createAdminSeason(payload: CreateSeasonPayload) { + const result = await api.createAdminSeason(payload) + await this.refreshAdminSeasonWorkspace(result.seasonId, { reloadAdmin: true, reloadHome: true }) + return result + }, + async deleteAdminSeason(seasonId: number) { + const result = await api.deleteAdminSeason(seasonId) + await this.refreshAfterSeasonListMutation({ reloadHome: true }) return result }, async createAdminCategory(seasonId: number, payload: UpsertCategoryPayload) { const result = await api.createAdminCategory(seasonId, payload) - await this.loadAdminSeasonDetail(seasonId) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true }) return result }, async updateAdminCategory(categoryId: number, seasonId: number, payload: UpsertCategoryPayload) { const result = await api.updateAdminCategory(categoryId, payload) - await this.loadAdminSeasonDetail(seasonId) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true }) return result }, async createAdminCandidate(seasonId: number, payload: UpsertCandidatePayload) { const result = await api.createAdminCandidate(seasonId, payload) - await this.loadAdminSeasonDetail(seasonId) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true }) return result }, async updateAdminCandidate(candidateId: number, seasonId: number, payload: UpsertCandidatePayload) { const result = await api.updateAdminCandidate(candidateId, payload) - await this.loadAdminSeasonDetail(seasonId) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true }) return result }, async deleteAdminCandidate(candidateId: number, seasonId: number) { const result = await api.deleteAdminCandidate(candidateId) - await this.loadAdminSeasonDetail(seasonId) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true }) return result }, async deleteAdminCategory(categoryId: number, seasonId: number) { const result = await api.deleteAdminCategory(categoryId) - await this.loadAdminSeasonDetail(seasonId) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true }) return result }, async deleteAdminClip(clipId: number, seasonId: number) { const result = await api.deleteAdminClip(clipId) - await this.loadAdminSeasonDetail(seasonId) + await this.refreshAdminSeasonWorkspace(seasonId) + return result + }, + async updateAdminClipStatus(clipId: number, seasonId: number, payload: UpdateClipStatusPayload) { + const result = await api.updateAdminClipStatus(clipId, payload) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true }) + return result + }, + async setAdminResult(seasonId: number, payload: SetAwardResultPayload) { + const result = await api.setAdminResult(seasonId, payload) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true }) + return result + }, + async deleteAdminResult(resultId: number, seasonId: number) { + const result = await api.deleteAdminResult(resultId) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true }) return result }, async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) { const result = await api.approveAdminNomination(nominationId, payload) - await this.loadAdminSeasonDetail(seasonId) - await this.loadAdmin() + await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true }) return result }, - async rejectAdminNomination(nominationId: number, seasonId: number) { - const result = await api.rejectAdminNomination(nominationId) - await this.loadAdminSeasonDetail(seasonId) - await this.loadAdmin() + async rejectAdminNomination(nominationId: number, seasonId: number, payload: RejectNominationPayload) { + const result = await api.rejectAdminNomination(nominationId, payload) + await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true }) return result }, - async resolveRiskFlag(riskFlagId: number, status = 'resolved') { - const result = await api.resolveRiskFlag(riskFlagId, status) - await this.loadAdmin() + async resolveRiskFlag(riskFlagId: number, payload: ResolveRiskFlagPayload) { + const result = await api.resolveRiskFlag(riskFlagId, payload) + await Promise.all([ + this.loadAdminRiskFlagsPage({ + limit: this.adminRiskFlagsPage.limit || 25, + offset: this.adminRiskFlagsPage.offset, + status: 'open', + }), + this.loadAdminRiskHistoryPage({ + limit: this.adminRiskHistoryPage.limit || 25, + offset: this.adminRiskHistoryPage.offset, + }), + ]) + return result + }, + async bulkResolveRiskFlags(payload: import('../types/awards').BulkResolveRiskFlagsPayload) { + const result = await api.bulkResolveRiskFlags(payload) + await Promise.all([ + this.loadAdminRiskFlagsPage({ + limit: this.adminRiskFlagsPage.limit || 25, + offset: this.adminRiskFlagsPage.offset, + status: 'open', + }), + this.loadAdminRiskHistoryPage({ + limit: this.adminRiskHistoryPage.limit || 25, + offset: this.adminRiskHistoryPage.offset, + }), + ]) + return result + }, + async loadAdminRiskRules() { + return api.getAdminRiskRules() + }, + async updateAdminRiskRules(payload: import('../types/awards').UpdateRiskRulesPayload) { + return api.updateAdminRiskRules(payload) + }, + async updateAdminSiteSettings(payload: UpdateSiteSettingsPayload) { + const result = await api.updateAdminSiteSettings(payload) + this.adminSiteSettings = await api.getAdminSiteSettings() + await this.loadHomeData() return result }, }, }) + +export type AwardsStore = ReturnType diff --git a/frontend/src/stores/awards/defaults.ts b/frontend/src/stores/awards/defaults.ts new file mode 100644 index 0000000..f2473e4 --- /dev/null +++ b/frontend/src/stores/awards/defaults.ts @@ -0,0 +1,184 @@ +import { ApiRequestError } from '../../lib/http' +import type { + AdminDashboardResponse, + AdminRiskFlag, + AdminRiskFlagsResponse, + AdminSeasonDetailResponse, + AdminSeasonListItem, + AdminSiteSettingsResponse, + DatabaseHealthResponse, + OverviewResponse, + SeasonCategoriesResponse, + WinnerArchiveResponse, +} from '../../types/awards' + +export type ApiMode = 'api' | 'fallback' +export type PublicErrorKind = 'offline' | 'unreachable' | 'server' | null + +export function createEmptyOverview(): OverviewResponse { + return { + seasonId: 0, + year: new Date().getFullYear(), + title: '', + showDate: '', + showStartsAt: '20:00:00', + showStreamUrl: '', + currentPhase: '', + isCommunityOnly: true, + loginProvider: 'Twitch', + timeline: [], + featuredCategories: [], + winnersPreview: [], + siteContent: { + hostDisplayName: '', + hostTagline: '', + newsletterUrl: '', + privacyEmail: '', + privacyPolicyContent: '', + socialLinks: [], + footerLinks: [], + }, + faq: [], + } +} + +export function createEmptyCategories(): SeasonCategoriesResponse { + return { + seasonId: 0, + year: new Date().getFullYear(), + categories: [], + } +} + +export function createEmptyArchive(year = new Date().getFullYear() - 1): WinnerArchiveResponse { + return { + year, + items: [], + } +} + +export function createEmptyAdminDashboard(): AdminDashboardResponse { + return { + metrics: [], + activities: [], + topCategories: [], + riskFlags: [], + auditEntries: [], + } +} + +export function createEmptyAdminRiskFlagsResponse(): AdminRiskFlagsResponse { + return { + items: [], + totalCount: 0, + returnedCount: 0, + offset: 0, + limit: 25, + hasMore: false, + severityCounts: [], + statusCounts: [], + } +} + +export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse { + return { + id: 0, + year: 0, + name: '', + showStreamUrl: '', + currentPhase: '', + isCurrent: false, + isCommunityOnly: true, + nominationStartsAt: '', + nominationEndsAt: '', + votingStartsAt: '', + votingEndsAt: '', + reviewStartsAt: '', + reviewEndsAt: '', + showDate: '', + showStartsAt: '20:00:00', + categories: [], + candidates: [], + pendingNominations: [], + reviewedNominations: [], + results: [], + clipSubmissions: [], + } +} + +export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse { + return { + hostDisplayName: '', + hostTagline: '', + newsletterUrl: '', + privacyEmail: '', + privacyPolicyContent: '', + privacyPolicyUpdatedBy: null, + privacyPolicyUpdatedAt: null, + imprintUrl: '', + contactUrl: '', + sponsorsUrl: '', + socialLinks: [], + faq: [], + } +} + +export function createEmptyDatabaseHealth(): DatabaseHealthResponse { + return { + provider: 'postgres', + canConnect: false, + pendingMigrations: [], + configuredConnection: { + source: 'unknown', + }, + } +} + +export function createAwardsState() { + return { + overview: createEmptyOverview(), + categories: createEmptyCategories(), + archive: createEmptyArchive(), + admin: createEmptyAdminDashboard(), + adminSeasons: [] as AdminSeasonListItem[], + adminSeasonDetail: createEmptyAdminSeasonDetail(), + adminSiteSettings: createEmptyAdminSiteSettings(), + adminRiskHistory: [] as AdminRiskFlag[], + adminRiskFlagsPage: createEmptyAdminRiskFlagsResponse(), + adminRiskHistoryPage: createEmptyAdminRiskFlagsResponse(), + databaseHealth: createEmptyDatabaseHealth(), + adminSelectedSeasonId: null as number | null, + loading: false, + apiMode: 'fallback' as ApiMode, + lastPublicError: null as string | null, + lastPublicErrorKind: null as PublicErrorKind, + } +} + +/** + * Guarantee the array fields exist even if a (possibly older) backend omits them, + * so views can safely read `.length`/`.filter` without crashing the render. + */ +export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse { + return { + ...detail, + categories: detail.categories ?? [], + candidates: detail.candidates ?? [], + pendingNominations: detail.pendingNominations ?? [], + reviewedNominations: detail.reviewedNominations ?? [], + results: detail.results ?? [], + clipSubmissions: detail.clipSubmissions ?? [], + } +} + +export function classifyPublicLoadError(error: unknown) { + const message = error instanceof Error ? error.message : 'Die Landingpage konnte nicht geladen werden.' + const isOffline = typeof navigator !== 'undefined' && navigator.onLine === false + const isUnreachable = message.toLowerCase().includes('api nicht erreichbar') + const isServerError = error instanceof ApiRequestError && error.status !== null && error.status >= 500 + + return { + message, + kind: isOffline ? 'offline' : isServerError ? 'server' : isUnreachable ? 'unreachable' : null, + } satisfies { message: string; kind: PublicErrorKind } +} diff --git a/frontend/src/style.css b/frontend/src/style.css index ea84dc2..7bc7db5 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,4 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Fredoka:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&family=Sacramento&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Fredoka:wght@400;500;600;700&family=Great+Vibes&family=Outfit:wght@300;400;500;600;700&display=swap"); @import "tailwindcss"; @import "primeicons/primeicons.css"; @@ -6,7 +6,7 @@ --font-display: "Cormorant Garamond", serif; --font-sans: "Outfit", sans-serif; --font-wordmark: "Fredoka", sans-serif; - --font-script: "Sacramento", cursive; + --font-script: "Great Vibes", cursive; } html, @@ -15,6 +15,10 @@ body, min-height: 100%; } +html { + scroll-behavior: smooth; +} + body { font-family: var(--font-sans); color: #3f3556; @@ -37,6 +41,259 @@ details > summary::-webkit-details-marker { display: none; } +.cinematic-loader-enter-active { + transition: opacity 0.34s ease; +} + +.cinematic-loader-enter-from { + opacity: 0; +} + +.cinematic-loader-leave-active { + pointer-events: none; + animation: loaderLayerRelease 3.7s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.cinematic-loader-leave-active .loader { + animation: loaderSoftExit 3.7s cubic-bezier(0.22, 1, 0.36, 1) forwards; + transform-origin: 50% 34%; +} + +.cinematic-loader-leave-active .loader__scene { + animation: loaderSceneHoldThenFade 3.48s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.cinematic-loader-leave-active .loader__aurora, +.cinematic-loader-leave-active .loader__hanger, +.cinematic-loader-leave-active .loader__mist { + animation: loaderMotifStepBack 0.5s ease-out forwards; +} + +.cinematic-loader-leave-active .loader__eyebrow, +.cinematic-loader-leave-active .loader__title, +.cinematic-loader-leave-active .loader__text { + animation: loaderLoadingTextOut 0.34s ease-out forwards; +} + +.cinematic-loader-leave-active .loader__outro-text { + animation: loaderOutroTextInOut 3.08s cubic-bezier(0.22, 1, 0.36, 1) 0.44s forwards; +} + +.cinematic-loader-leave-active .loader__starfield--far, +.cinematic-loader-leave-active .loader__starfield--mid, +.cinematic-loader-leave-active .loader__starfield--near { + animation: loaderStarsDriftAway 3.7s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-line--one { + animation: constellationLineDraw 3s cubic-bezier(0.22, 1, 0.36, 1) 0.24s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-line--two { + animation: constellationLineDraw 3s cubic-bezier(0.22, 1, 0.36, 1) 0.36s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-line--three { + animation: constellationLineDraw 3s cubic-bezier(0.22, 1, 0.36, 1) 0.48s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-line--four { + animation: constellationLineDraw 3s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-line--five { + animation: constellationLineDraw 3s cubic-bezier(0.22, 1, 0.36, 1) 0.72s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-line--six { + animation: constellationLineDraw 3s cubic-bezier(0.22, 1, 0.36, 1) 0.84s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-line--seven { + animation: constellationLineDraw 3s cubic-bezier(0.22, 1, 0.36, 1) 0.96s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--one { + animation: constellationDotOne 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.16s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--two { + animation: constellationDotTwo 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.26s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--three { + animation: constellationDotThree 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.36s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--four { + animation: constellationDotFour 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.46s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--five { + animation: constellationDotFive 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.56s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--six { + animation: constellationDotSix 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.66s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--seven { + animation: constellationDotSeven 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.76s forwards; +} + +.cinematic-loader-leave-active .loader__dissolve-dot--eight { + animation: constellationDotEight 3.04s cubic-bezier(0.22, 1, 0.36, 1) 0.86s forwards; +} + +@keyframes loaderLayerRelease { + 0% { opacity: 1; } + 88% { opacity: 1; } + 100% { opacity: 0; } +} + +@keyframes loaderSoftExit { + 0% { + opacity: 1; + transform: scale(1); + } + 90% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1); + } +} + +@keyframes loaderSceneHoldThenFade { + 0% { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + 88% { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + 100% { + opacity: 0; + transform: translate3d(0, -0.4rem, 0) scale(0.98); + } +} + +@keyframes loaderMotifStepBack { + 0% { + opacity: 1; + } + 100% { + opacity: 0.18; + } +} + +@keyframes loaderLoadingTextOut { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-0.45rem); + } +} + +@keyframes loaderOutroTextInOut { + 0% { + opacity: 0; + transform: translateY(-6.2rem) scale(0.98); + } + 18% { + opacity: 1; + transform: translateY(-6.8rem) scale(1); + } + 78% { + opacity: 1; + transform: translateY(-6.8rem) scale(1); + } + 100% { + opacity: 0; + transform: translateY(-7.2rem) scale(0.99); + } +} + +@keyframes loaderStarsDriftAway { + 0% { + opacity: 0.72; + transform: translate3d(0, 0, 0) scale(1); + } + 100% { + opacity: 0; + transform: translate3d(0, -3rem, 0) scale(1.05); + } +} + +@keyframes constellationLineDraw { + 0% { opacity: 0; stroke-dashoffset: 1; } + 20% { opacity: 0.94; stroke-dashoffset: 0; } + 68% { opacity: 0.86; stroke-dashoffset: 0; } + 100% { opacity: 0; stroke-dashoffset: 0; } +} + +@keyframes constellationDotOne { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.25); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(-42px, -46px, 0) scale(0.18); } +} + +@keyframes constellationDotTwo { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.25); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(2px, -56px, 0) scale(0.18); } +} + +@keyframes constellationDotThree { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.25); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(52px, -30px, 0) scale(0.18); } +} + +@keyframes constellationDotFour { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.25); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(-48px, 34px, 0) scale(0.18); } +} + +@keyframes constellationDotFive { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.25); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(14px, 48px, 0) scale(0.18); } +} + +@keyframes constellationDotSix { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.25); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(58px, 40px, 0) scale(0.18); } +} + +@keyframes constellationDotSeven { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.18); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(64px, 66px, 0) scale(0.18); } +} + +@keyframes constellationDotEight { + 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); } + 16% { opacity: 1; transform: translate3d(0, 0, 0) scale(1.18); } + 68% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(-62px, 62px, 0) scale(0.18); } +} + @keyframes floaty { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-18px); } diff --git a/frontend/src/types/awards.ts b/frontend/src/types/awards.ts index f7fd344..8e54843 100644 --- a/frontend/src/types/awards.ts +++ b/frontend/src/types/awards.ts @@ -1,251 +1,4 @@ -export interface TimelineItem { - key: string - title: string - startsAt: string - endsAt: string - state: 'done' | 'active' | 'upcoming' -} - -export interface FeaturedCategory { - id: number - groupName: string - name: string - description: string - maxNomineesPerUser: number -} - -export interface WinnerPreview { - year: number - category: string - winnerName: string - winnerSlug: string -} - -export interface FaqItem { - question: string - answer: string -} - -export interface OverviewResponse { - seasonId: number - year: number - title: string - showDate: string - currentPhase: string - isCommunityOnly: boolean - loginProvider: string - timeline: TimelineItem[] - featuredCategories: FeaturedCategory[] - winnersPreview: WinnerPreview[] - faq: FaqItem[] -} - -export interface CandidateSummary { - id: number - displayName: string - channelSlug: string - platform: string - /** Repräsentativer Clip/Video-Link, damit Votende vor der Wahl reinschauen können. */ - clipUrl?: string -} - -export interface PublicCategoryDetail { - id: number - name: string - groupName: string - description: string - maxNomineesPerUser: number - candidates: CandidateSummary[] -} - -export interface SeasonCategoriesResponse { - seasonId: number - year: number - categories: PublicCategoryDetail[] -} - -export interface WinnerArchiveItem { - category: string - winnerName: string - winnerSlug: string -} - -export interface WinnerArchiveResponse { - year: number - items: WinnerArchiveItem[] -} - -export interface AdminMetric { - label: string - value: number - note: string -} - -export interface AdminActivity { - label: string - age: string -} - -export interface AdminTopCategory { - category: string - votes: number -} - -export interface AdminRiskFlag { - id: number - source: string - type: string - severity: string - status: string - summary: string - twitchUserId: string | null - createdFromIp: string - createdAt: string - metadataJson: string -} - -export interface AdminAuditEntry { - id: number - adminTwitchUserId: string - actionType: string - entityType: string - entityId: string - summary: string - createdAt: string -} - -export interface AdminDashboardResponse { - metrics: AdminMetric[] - activities: AdminActivity[] - topCategories: AdminTopCategory[] - riskFlags: AdminRiskFlag[] - auditEntries: AdminAuditEntry[] -} - -export interface AdminSeasonListItem { - id: number - year: number - name: string - currentPhase: string - isCurrent: boolean - categoryCount: number -} - -export interface AdminCategoryItem { - id: number - groupName: string - name: string - slug: string - description: string - sortOrder: number - maxNomineesPerUser: number - candidateCount: number -} - -export interface AdminCandidateItem { - id: number - categoryId: number - displayName: string - channelSlug: string - platform: string -} - -export interface AdminNominationReviewItem { - id: number - categoryId: number - categoryName: string - submittedByTwitchId: string - candidateText: string - createdAt: string -} - -export interface AdminClipSubmissionItem { - id: number - categoryId: number | null - submittedByTwitchId: string - clipUrl: string - title: string - creator: string - platform: string - status: string - createdAt: string -} - -export interface AdminSeasonDetailResponse { - id: number - year: number - name: string - currentPhase: string - isCurrent: boolean - categories: AdminCategoryItem[] - candidates: AdminCandidateItem[] - pendingNominations: AdminNominationReviewItem[] - clipSubmissions: AdminClipSubmissionItem[] -} - -export interface CreateNominationPayload { - year: number - categoryId: number - twitchUserId: string - nominees: string[] -} - -export interface CreateClipPayload { - year: number - categoryId: number | null - twitchUserId: string - clipUrl: string - title: string - creator: string -} - -export interface VoteEntryPayload { - categoryId: number - candidateId: number -} - -export interface CreateVotePayload { - seasonId: number - twitchUserId: string - entries: VoteEntryPayload[] -} - -export interface UpdateSeasonPayload { - currentPhase: string - isCurrent: boolean -} - -export interface UpsertCategoryPayload { - groupName: string - name: string - slug: string - description: string - sortOrder: number - maxNomineesPerUser: number -} - -export interface UpsertCandidatePayload { - categoryId: number - displayName: string - channelSlug: string - platform: string -} - -export interface ApproveNominationPayload { - displayName: string - channelSlug: string - platform: string -} - -export interface AuthSession { - sessionToken: string - twitchUserId: string - displayName: string - role: 'viewer' | 'admin' -} - -export interface LoginPayload { - twitchUserId: string - displayName: string - role: 'viewer' | 'admin' -} +export type * from './awards/admin' +export type * from './awards/auth' +export type * from './awards/payloads' +export type * from './awards/public' diff --git a/frontend/src/types/awards/admin.ts b/frontend/src/types/awards/admin.ts new file mode 100644 index 0000000..f57796e --- /dev/null +++ b/frontend/src/types/awards/admin.ts @@ -0,0 +1,251 @@ +import type { FaqItem, PublicSocialLink } from './public' + +export interface AdminMetric { + label: string + value: number + note: string +} + +export interface AdminActivity { + label: string + age: string +} + +export interface AdminTopCategory { + category: string + votes: number +} + +export interface AdminRiskFlag { + id: number + source: string + type: string + severity: string + status: string + summary: string + twitchUserId: string | null + createdFromIp: string + createdAt: string + metadataJson: string + reviewNote: string | null + reviewedByTwitchId: string | null + reviewedAt: string | null + entityLinks: AdminRiskEntityLink[] +} + +export interface AdminRiskEntityLink { + label: string + entityType: string + entityId: string + to: string +} + +export interface AdminRiskCount { + key: string + count: number +} + +export interface AdminRiskFlagsResponse { + items: AdminRiskFlag[] + totalCount: number + returnedCount: number + offset: number + limit: number + hasMore: boolean + severityCounts: AdminRiskCount[] + statusCounts: AdminRiskCount[] +} + +export interface AdminRiskQueryOptions { + limit?: number + offset?: number + status?: string + severity?: string + query?: string + reviewedOnly?: boolean +} + +export interface AdminRiskRule { + key: string + label: string + enabled: boolean + threshold: number + windowMinutes: number + severity: string + description: string +} + +export interface AdminRiskRulesResponse { + rules: AdminRiskRule[] +} + +export interface AdminAuditEntry { + id: number + adminTwitchUserId: string + actionType: string + entityType: string + entityId: string + summary: string + createdAt: string + metadataJson: string + createdFromIp: string + userAgent: string +} + +export interface AdminAuditEntriesResponse { + items: AdminAuditEntry[] + totalCount: number + returnedCount: number + nextCursor: string | null + limit: number +} + +export interface AdminAuditQueryOptions { + limit?: number + query?: string + admin?: string + action?: string + entityType?: string + from?: string + to?: string + cursor?: string | null +} + +export interface AdminDashboardResponse { + metrics: AdminMetric[] + activities: AdminActivity[] + topCategories: AdminTopCategory[] + riskFlags: AdminRiskFlag[] + auditEntries: AdminAuditEntry[] +} + +export interface DatabaseHealthResponse { + provider: string + canConnect: boolean + pendingMigrations: string[] + configuredConnection: { + source: string + } + error?: string | null +} + +export interface AdminSeasonListItem { + id: number + year: number + name: string + currentPhase: string + isCurrent: boolean + categoryCount: number +} + +export interface AdminCategoryItem { + id: number + groupName: string + name: string + slug: string + description: string + sortOrder: number + maxNomineesPerUser: number + candidateCount: number +} + +export interface AdminCandidateItem { + id: number + categoryId: number + displayName: string + channelSlug: string + platform: string +} + +export interface AdminAwardResultItem { + id: number + categoryId: number + categoryName: string + candidateId: number + candidateDisplayName: string + candidateChannelSlug: string + candidatePlatform: string +} + +export interface AdminNominationReviewItem { + id: number + categoryId: number + categoryName: string + submittedByTwitchId: string + candidateText: string + streamUrl: string | null + status: string + createdAt: string + candidateId: number | null + candidateDisplayName: string | null + reviewNote: string | null + reviewedByTwitchId: string | null + reviewedAt: string | null +} + +export interface AdminClipSubmissionItem { + id: number + categoryId: number | null + candidateId: number | null + submittedByTwitchId: string + clipUrl: string + title: string + creator: string + platform: string + status: string + createdAt: string + reviewNote: string | null + reviewedByTwitchId: string | null + reviewedAt: string | null +} + +export interface AdminSeasonDetailResponse { + id: number + year: number + name: string + showStreamUrl: string + currentPhase: string + isCurrent: boolean + isCommunityOnly: boolean + nominationStartsAt: string + nominationEndsAt: string + votingStartsAt: string + votingEndsAt: string + reviewStartsAt: string + reviewEndsAt: string + showDate: string + showStartsAt: string + categories: AdminCategoryItem[] + candidates: AdminCandidateItem[] + pendingNominations: AdminNominationReviewItem[] + reviewedNominations: AdminNominationReviewItem[] + results: AdminAwardResultItem[] + clipSubmissions: AdminClipSubmissionItem[] +} + +export interface AdminSiteSettingsResponse { + hostDisplayName: string + hostTagline: string + newsletterUrl: string + privacyEmail: string + privacyPolicyContent: string + privacyPolicyUpdatedBy: string | null + privacyPolicyUpdatedAt: string | null + imprintUrl: string + contactUrl: string + sponsorsUrl: string + socialLinks: PublicSocialLink[] + faq: FaqItem[] +} + +export interface AdminOperationalSettingsResponse { + demoLoginManagedByDatabase: boolean + demoLoginEnabled: boolean + demoLoginEmail: string + demoLoginPasswordSet: boolean + demoLoginTwitchUserId: string + demoLoginDisplayName: string + maintenanceModeEnabled: boolean + maintenanceTitle: string + maintenanceMessage: string +} diff --git a/frontend/src/types/awards/auth.ts b/frontend/src/types/awards/auth.ts new file mode 100644 index 0000000..13f1066 --- /dev/null +++ b/frontend/src/types/awards/auth.ts @@ -0,0 +1,19 @@ +export type AuthRole = 'viewer' | 'content_admin' | 'admin' | 'owner' + +export interface AuthSession { + sessionToken: string + twitchUserId: string + displayName: string + role: AuthRole +} + +export interface LoginPayload { + twitchUserId: string + displayName: string + role: AuthRole +} + +export interface DemoLoginPayload { + login: string + password: string +} diff --git a/frontend/src/types/awards/payloads.ts b/frontend/src/types/awards/payloads.ts new file mode 100644 index 0000000..f97d764 --- /dev/null +++ b/frontend/src/types/awards/payloads.ts @@ -0,0 +1,154 @@ +import type { FaqItem, PublicSocialLink } from './public' + +export interface CreateNominationPayload { + year: number + categoryId: number + twitchUserId: string + nominees?: string[] + nominations?: Array<{ + name: string + streamUrl: string + }> +} + +export interface CreateClipPayload { + year: number + categoryId: number | null + candidateId: number | null + twitchUserId: string + clipUrl: string + title: string + creator: string +} + +export interface VoteEntryPayload { + categoryId: number + candidateId: number +} + +export interface CreateVotePayload { + seasonId: number + twitchUserId: string + entries: VoteEntryPayload[] +} + +export interface CreateSeasonPayload { + year: number + name: string + showStreamUrl: string + currentPhase: string + isCurrent: boolean + isCommunityOnly: boolean + nominationStartsAt: string + nominationEndsAt: string + votingStartsAt: string + votingEndsAt: string + reviewStartsAt: string + reviewEndsAt: string + showDate: string + showStartsAt: string + copyStructureFromSeasonId?: number | null +} + +export interface UpdateSeasonPayload { + year: number + name: string + showStreamUrl: string + currentPhase: string + isCurrent: boolean + isCommunityOnly: boolean + nominationStartsAt: string + nominationEndsAt: string + votingStartsAt: string + votingEndsAt: string + reviewStartsAt: string + reviewEndsAt: string + showDate: string + showStartsAt: string +} + +export interface UpsertCategoryPayload { + groupName: string + name: string + slug: string + description: string + sortOrder: number + maxNomineesPerUser: number +} + +export interface UpsertCandidatePayload { + categoryId: number + displayName: string + channelSlug: string + platform: string +} + +export interface UpdateClipStatusPayload { + status: string + reviewNote?: string +} + +export interface ResolveRiskFlagPayload { + status: string + reviewNote?: string +} + +export interface BulkResolveRiskFlagsPayload { + riskFlagIds: number[] + status: string + reviewNote?: string +} + +export interface UpdateRiskRulePayload { + key: string + label: string + enabled: boolean + threshold: number + windowMinutes: number + severity: string + description: string +} + +export interface UpdateRiskRulesPayload { + rules: UpdateRiskRulePayload[] +} + +export interface SetAwardResultPayload { + categoryId: number + candidateId: number +} + +export interface ApproveNominationPayload { + displayName: string + channelSlug: string + platform: string + reviewNote?: string +} + +export interface RejectNominationPayload { + reviewNote?: string +} + +export interface UpdateSiteSettingsPayload { + hostDisplayName: string + hostTagline: string + newsletterUrl: string + privacyEmail: string + privacyPolicyContent: string + imprintUrl: string + contactUrl: string + sponsorsUrl: string + socialLinks: PublicSocialLink[] + faq: FaqItem[] +} + +export interface UpdateOperationalSettingsPayload { + demoLoginEnabled: boolean + demoLoginEmail: string + demoLoginPassword?: string + demoLoginTwitchUserId: string + demoLoginDisplayName: string + maintenanceModeEnabled: boolean + maintenanceTitle: string + maintenanceMessage: string +} diff --git a/frontend/src/types/awards/public.ts b/frontend/src/types/awards/public.ts new file mode 100644 index 0000000..7a91ffb --- /dev/null +++ b/frontend/src/types/awards/public.ts @@ -0,0 +1,147 @@ +export interface TimelineItem { + key: string + title: string + startsAt: string + endsAt: string + state: 'done' | 'active' | 'upcoming' +} + +export interface FeaturedCategory { + id: number + groupName: string + name: string + description: string + maxNomineesPerUser: number +} + +export interface WinnerPreview { + year: number + category: string + winnerName: string + winnerSlug: string + winnerPlatform: string + winnerUrl: string +} + +export interface FaqItem { + question: string + answer: string +} + +export interface PublicSocialLink { + label: string + platform: string + url: string + icon?: string | null + showOnHost?: boolean | null + showOnCommunity?: boolean | null +} + +export interface FooterLink { + label: string + url: string +} + +export interface PublicSiteContent { + hostDisplayName: string + hostTagline: string + newsletterUrl: string + privacyEmail: string + privacyPolicyContent: string + socialLinks: PublicSocialLink[] + footerLinks: FooterLink[] +} + +export interface PublicSiteStatusResponse { + demoLoginEnabled: boolean + maintenanceModeEnabled: boolean + maintenanceTitle: string + maintenanceMessage: string +} + +export interface OverviewResponse { + seasonId: number + year: number + title: string + showDate: string + showStartsAt: string + showStreamUrl: string + currentPhase: string + isCommunityOnly: boolean + loginProvider: string + timeline: TimelineItem[] + featuredCategories: FeaturedCategory[] + winnersPreview: WinnerPreview[] + siteContent: PublicSiteContent + faq: FaqItem[] +} + +export interface CandidateSummary { + id: number + displayName: string + channelSlug: string + platform: string + /** Repräsentativer Clip/Video-Link, damit Votende vor der Wahl reinschauen können. */ + clipUrl?: string | null + clipTitle?: string | null + clipPlatform?: string | null +} + +export interface PublicCategoryDetail { + id: number + name: string + groupName: string + description: string + maxNomineesPerUser: number + candidates: CandidateSummary[] +} + +export interface SeasonCategoriesResponse { + seasonId: number + year: number + categories: PublicCategoryDetail[] +} + +export interface WinnerArchiveItem { + category: string + winnerName: string + winnerSlug: string + winnerPlatform: string + winnerUrl: string +} + +export interface WinnerArchiveResponse { + year: number + items: WinnerArchiveItem[] +} + +export interface UserNominationState { + categoryId: number + nominees: string[] +} + +export interface UserVoteState { + categoryId: number + candidateId: number +} + +export interface UserClipSubmissionState { + id: number + categoryId: number | null + clipUrl: string + title: string + creator: string + platform: string + status: string + createdAt: string + reviewNote: string | null + reviewedAt: string | null +} + +export interface UserParticipationResponse { + seasonId: number + year: number + nominations: UserNominationState[] + votes: UserVoteState[] + clipSubmissions: UserClipSubmissionState[] +} diff --git a/frontend/src/views/ClipSubmissionView.vue b/frontend/src/views/ClipSubmissionView.vue deleted file mode 100644 index ed76e95..0000000 --- a/frontend/src/views/ClipSubmissionView.vue +++ /dev/null @@ -1,169 +0,0 @@ - - - diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 37ce75e..519a4ec 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,707 +1,7 @@ diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..1cadfb9 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/frontend/src/views/MaintenanceView.vue b/frontend/src/views/MaintenanceView.vue new file mode 100644 index 0000000..8569e14 --- /dev/null +++ b/frontend/src/views/MaintenanceView.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/src/views/NetworkErrorView.vue b/frontend/src/views/NetworkErrorView.vue new file mode 100644 index 0000000..55e0fb5 --- /dev/null +++ b/frontend/src/views/NetworkErrorView.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/views/NominationsView.vue b/frontend/src/views/NominationsView.vue deleted file mode 100644 index f18a18c..0000000 --- a/frontend/src/views/NominationsView.vue +++ /dev/null @@ -1,209 +0,0 @@ - - - - - diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue new file mode 100644 index 0000000..e2b4585 --- /dev/null +++ b/frontend/src/views/NotFoundView.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/views/VotingView.vue b/frontend/src/views/VotingView.vue deleted file mode 100644 index 2985ea0..0000000 --- a/frontend/src/views/VotingView.vue +++ /dev/null @@ -1,189 +0,0 @@ - - - diff --git a/frontend/src/views/WinnersView.vue b/frontend/src/views/WinnersView.vue deleted file mode 100644 index a7e9750..0000000 --- a/frontend/src/views/WinnersView.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminAnalyticsView.vue b/frontend/src/views/admin/AdminAnalyticsView.vue index 9dfd6b5..8bb0c9b 100644 --- a/frontend/src/views/admin/AdminAnalyticsView.vue +++ b/frontend/src/views/admin/AdminAnalyticsView.vue @@ -1,65 +1,29 @@