diff --git a/Backend/Common/SeasonMappings.cs b/Backend/Common/SeasonMappings.cs index aa23e92..486143f 100644 --- a/Backend/Common/SeasonMappings.cs +++ b/Backend/Common/SeasonMappings.cs @@ -92,6 +92,50 @@ public static class SeasonMappings }; } + public static (string Platform, string Slug) InferProfileMetadataFromUrl(string? value, string fallbackName = "") + { + var trimmed = value?.Trim() ?? string.Empty; + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + return ("Profil", fallbackName.Trim()); + } + + var host = uri.Host.Trim().ToLowerInvariant(); + var segments = uri.AbsolutePath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var platform = host switch + { + var item when item.Contains("twitch.tv", StringComparison.Ordinal) => "Twitch", + var item when item.Contains("youtube.com", StringComparison.Ordinal) || item.Contains("youtu.be", StringComparison.Ordinal) => "YouTube", + var item when item.Contains("x.com", StringComparison.Ordinal) || item.Contains("twitter.com", StringComparison.Ordinal) => "X", + var item when item.Contains("instagram.com", StringComparison.Ordinal) => "Instagram", + var item when item.Contains("discord.gg", StringComparison.Ordinal) || item.Contains("discord.com", StringComparison.Ordinal) => "Discord", + var item when item.Contains("kick.com", StringComparison.Ordinal) => "Kick", + var item when item.Contains("cake.gg", StringComparison.Ordinal) => "Cake", + _ => "Profil", + }; + + var slug = segments.LastOrDefault() ?? string.Empty; + if (string.Equals(platform, "YouTube", StringComparison.Ordinal) && segments.Length > 0) + { + slug = segments.FirstOrDefault(segment => segment.StartsWith('@')) ?? slug; + } + + slug = Uri.UnescapeDataString(slug).Trim().Trim('/'); + if (slug.StartsWith('@') && !string.Equals(platform, "YouTube", StringComparison.Ordinal)) + { + slug = slug[1..]; + } + + if (string.IsNullOrWhiteSpace(slug)) + { + slug = fallbackName.Trim(); + } + + return (platform, slug); + } + public static string NormalizeSeasonStreamUrl(string? value) { var trimmed = value?.Trim() ?? string.Empty; diff --git a/Backend/Contracts/AdminArchiveContracts.cs b/Backend/Contracts/AdminArchiveContracts.cs new file mode 100644 index 0000000..ee9aa59 --- /dev/null +++ b/Backend/Contracts/AdminArchiveContracts.cs @@ -0,0 +1,18 @@ +namespace Backend.Contracts; + +public sealed record AdminArchivedWinnerItemDto( + int Id, + int Year, + string Category, + string Subcategory, + string WinnerName, + string WinnerUrl, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); + +public sealed record UpsertArchivedWinnerRequest( + int Year, + string Category, + string Subcategory, + string WinnerName, + string WinnerUrl); diff --git a/Backend/Contracts/AdminSeasonContracts.cs b/Backend/Contracts/AdminSeasonContracts.cs index bd66909..b1f0dd9 100644 --- a/Backend/Contracts/AdminSeasonContracts.cs +++ b/Backend/Contracts/AdminSeasonContracts.cs @@ -37,6 +37,8 @@ public sealed record AdminCandidateItemDto( string DisplayName, string ChannelSlug, string Platform, + int? AvgViewers, + int Votes, int NominationTally, string AcceptanceStatus, string? AcceptanceNote, diff --git a/Backend/Contracts/AdminSiteSettingsContracts.cs b/Backend/Contracts/AdminSiteSettingsContracts.cs index e6ada26..0145f79 100644 --- a/Backend/Contracts/AdminSiteSettingsContracts.cs +++ b/Backend/Contracts/AdminSiteSettingsContracts.cs @@ -3,6 +3,8 @@ namespace Backend.Contracts; public sealed record AdminSiteSettingsResponse( string HostDisplayName, string HostTagline, + string HostArtistName, + string HostImageUrl, string NewsletterUrl, string ShareXUrl, string ShareDiscordUrl, @@ -41,6 +43,7 @@ public sealed record AdminSiteSettingsResponse( public sealed record UpdateSiteSettingsRequest( string HostDisplayName, string HostTagline, + string HostArtistName, string NewsletterUrl, string ShareXUrl, string ShareDiscordUrl, diff --git a/Backend/Contracts/PublicOverviewContracts.cs b/Backend/Contracts/PublicOverviewContracts.cs index 479d7d7..09dcfb5 100644 --- a/Backend/Contracts/PublicOverviewContracts.cs +++ b/Backend/Contracts/PublicOverviewContracts.cs @@ -64,6 +64,8 @@ public sealed record PublicStreamBannerContentDto( public sealed record PublicSiteContentDto( string HostDisplayName, string HostTagline, + string HostArtistName, + string HostImageUrl, string NewsletterUrl, string ShareXUrl, string ShareDiscordUrl, diff --git a/Backend/Data/AwardsDbContext.cs b/Backend/Data/AwardsDbContext.cs index 5772694..2b1fddd 100644 --- a/Backend/Data/AwardsDbContext.cs +++ b/Backend/Data/AwardsDbContext.cs @@ -10,6 +10,7 @@ public sealed class AwardsDbContext(DbContextOptions options) : public DbSet Candidates => Set(); public DbSet StreamerIdentities => Set(); public DbSet Results => Set(); + public DbSet ArchivedWinners => Set(); public DbSet Nominations => Set(); public DbSet VoteBallots => Set(); public DbSet VoteEntries => Set(); @@ -40,6 +41,9 @@ public sealed class AwardsDbContext(DbContextOptions options) : { entity.Property(item => item.HostDisplayName).HasMaxLength(120); entity.Property(item => item.HostTagline).HasMaxLength(160); + entity.Property(item => item.HostArtistName).HasMaxLength(120); + entity.Property(item => item.HostImageData).HasColumnType("bytea"); + entity.Property(item => item.HostImageContentType).HasMaxLength(80); entity.Property(item => item.NewsletterUrl).HasMaxLength(400); entity.Property(item => item.PrivacyEmail).HasMaxLength(160); entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120); @@ -186,6 +190,15 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.CategoryName).HasMaxLength(120); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(item => new { item.Year, item.Category, item.Subcategory }).IsUnique(); + entity.Property(item => item.Category).HasMaxLength(120); + entity.Property(item => item.Subcategory).HasMaxLength(120); + entity.Property(item => item.WinnerName).HasMaxLength(120); + entity.Property(item => item.WinnerUrl).HasMaxLength(500); + }); + modelBuilder.Entity(entity => { entity.HasIndex(item => item.SessionToken).IsUnique(); diff --git a/Backend/Domain/ArchivedWinner.cs b/Backend/Domain/ArchivedWinner.cs new file mode 100644 index 0000000..0a0dfd0 --- /dev/null +++ b/Backend/Domain/ArchivedWinner.cs @@ -0,0 +1,13 @@ +namespace Backend.Domain; + +public sealed class ArchivedWinner +{ + public int Id { get; set; } + public int Year { get; set; } + public string Category { get; set; } = string.Empty; + public string Subcategory { get; set; } = string.Empty; + public string WinnerName { get; set; } = string.Empty; + public string WinnerUrl { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? UpdatedAt { get; set; } +} diff --git a/Backend/Domain/SiteSettings.cs b/Backend/Domain/SiteSettings.cs index 3e01d81..e6d021b 100644 --- a/Backend/Domain/SiteSettings.cs +++ b/Backend/Domain/SiteSettings.cs @@ -5,6 +5,10 @@ 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 HostArtistName { get; set; } = string.Empty; + public byte[]? HostImageData { get; set; } + public string? HostImageContentType { get; set; } + public DateTimeOffset? HostImageUpdatedAt { get; set; } public string NewsletterUrl { get; set; } = string.Empty; public string ShareXUrl { get; set; } = string.Empty; public string ShareDiscordUrl { get; set; } = string.Empty; diff --git a/Backend/Endpoints/AdminArchiveEndpoints.cs b/Backend/Endpoints/AdminArchiveEndpoints.cs new file mode 100644 index 0000000..a29a447 --- /dev/null +++ b/Backend/Endpoints/AdminArchiveEndpoints.cs @@ -0,0 +1,249 @@ +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 AdminArchiveEndpoints +{ + public static RouteGroupBuilder MapAdminArchiveEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/archived-winners", GetArchivedWinners) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Winners)) + .WithName("GetAdminArchivedWinners"); + + group.MapPost("/archived-winners", CreateArchivedWinner) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners)) + .WithName("CreateAdminArchivedWinner"); + + group.MapPut("/archived-winners/{archivedWinnerId:int}", UpdateArchivedWinner) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners)) + .WithName("UpdateAdminArchivedWinner"); + + group.MapDelete("/archived-winners/{archivedWinnerId:int}", DeleteArchivedWinner) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners)) + .WithName("DeleteAdminArchivedWinner"); + + return group; + } + + private static async Task GetArchivedWinners(AwardsDbContext db, CancellationToken cancellationToken) + { + var items = await db.ArchivedWinners + .AsNoTracking() + .OrderByDescending(item => item.Year) + .ThenBy(item => item.Category) + .ThenBy(item => item.Subcategory) + .ThenBy(item => item.WinnerName) + .Select(item => ToDto(item)) + .ToArrayAsync(cancellationToken); + + return Results.Ok(items); + } + + private static async Task CreateArchivedWinner( + HttpContext context, + UpsertArchivedWinnerRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var validation = ValidateRequest(request); + if (validation is not null) + { + return validation; + } + + var normalized = NormalizeRequest(request); + var duplicateExists = await db.ArchivedWinners.AnyAsync(item => + item.Year == normalized.Year + && item.Category == normalized.Category + && item.Subcategory == normalized.Subcategory, + context.RequestAborted); + if (duplicateExists) + { + return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." }); + } + + var archivedWinner = new ArchivedWinner + { + Year = normalized.Year, + Category = normalized.Category, + Subcategory = normalized.Subcategory, + WinnerName = normalized.WinnerName, + WinnerUrl = normalized.WinnerUrl, + }; + + db.ArchivedWinners.Add(archivedWinner); + + adminAuditService.AddEntry( + session.TwitchUserId, + "archived-winner.create", + "archivedWinner", + $"{normalized.Year}:{normalized.Category}:{normalized.Subcategory}", + $"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} angelegt.", + new + { + normalized.Year, + normalized.Category, + normalized.Subcategory, + normalized.WinnerName, + normalized.WinnerUrl, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) }); + } + + private static async Task UpdateArchivedWinner( + HttpContext context, + int archivedWinnerId, + UpsertArchivedWinnerRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var validation = ValidateRequest(request); + if (validation is not null) + { + return validation; + } + + var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted); + if (archivedWinner is null) + { + return Results.NotFound(); + } + + var normalized = NormalizeRequest(request); + var duplicateExists = await db.ArchivedWinners.AnyAsync(item => + item.Id != archivedWinnerId + && item.Year == normalized.Year + && item.Category == normalized.Category + && item.Subcategory == normalized.Subcategory, + context.RequestAborted); + if (duplicateExists) + { + return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." }); + } + + archivedWinner.Year = normalized.Year; + archivedWinner.Category = normalized.Category; + archivedWinner.Subcategory = normalized.Subcategory; + archivedWinner.WinnerName = normalized.WinnerName; + archivedWinner.WinnerUrl = normalized.WinnerUrl; + archivedWinner.UpdatedAt = DateTimeOffset.UtcNow; + + adminAuditService.AddEntry( + session.TwitchUserId, + "archived-winner.update", + "archivedWinner", + archivedWinner.Id.ToString(), + $"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} aktualisiert.", + new + { + archivedWinner.Id, + normalized.Year, + normalized.Category, + normalized.Subcategory, + normalized.WinnerName, + normalized.WinnerUrl, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) }); + } + + private static async Task DeleteArchivedWinner( + HttpContext context, + int archivedWinnerId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted); + if (archivedWinner is null) + { + return Results.NotFound(); + } + + db.ArchivedWinners.Remove(archivedWinner); + + adminAuditService.AddEntry( + session.TwitchUserId, + "archived-winner.delete", + "archivedWinner", + archivedWinner.Id.ToString(), + $"Archivgewinner {archivedWinner.Year} · {archivedWinner.Category} · {archivedWinner.Subcategory} gelöscht.", + new + { + archivedWinner.Id, + archivedWinner.Year, + archivedWinner.Category, + archivedWinner.Subcategory, + archivedWinner.WinnerName, + archivedWinner.WinnerUrl, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, archivedWinnerId }); + } + + private static AdminArchivedWinnerItemDto ToDto(ArchivedWinner item) => + new( + item.Id, + item.Year, + item.Category, + item.Subcategory, + item.WinnerName, + item.WinnerUrl, + item.CreatedAt, + item.UpdatedAt); + + private static IResult? ValidateRequest(UpsertArchivedWinnerRequest request) + { + if (request.Year < 2000 || request.Year > 3000) + { + return Results.BadRequest(new { message = "Bitte ein gültiges Archivjahr angeben." }); + } + + if (string.IsNullOrWhiteSpace(request.Category)) + { + return Results.BadRequest(new { message = "Die Kategorie darf nicht leer sein." }); + } + + if (string.IsNullOrWhiteSpace(request.Subcategory)) + { + return Results.BadRequest(new { message = "Die Unterkategorie darf nicht leer sein." }); + } + + if (string.IsNullOrWhiteSpace(request.WinnerName)) + { + return Results.BadRequest(new { message = "Der Gewinnername darf nicht leer sein." }); + } + + var winnerUrl = request.WinnerUrl?.Trim() ?? string.Empty; + if (!Uri.TryCreate(winnerUrl, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return Results.BadRequest(new { message = "Bitte einen gültigen http- oder https-Link angeben." }); + } + + return null; + } + + private static (int Year, string Category, string Subcategory, string WinnerName, string WinnerUrl) NormalizeRequest(UpsertArchivedWinnerRequest request) => + ( + request.Year, + SeasonMappings.NormalizePlainTextContent(request.Category), + SeasonMappings.NormalizePlainTextContent(request.Subcategory), + SeasonMappings.NormalizePlainTextContent(request.WinnerName), + request.WinnerUrl.Trim()); +} diff --git a/Backend/Endpoints/AdminEndpoints.cs b/Backend/Endpoints/AdminEndpoints.cs index cb18b22..61398e8 100644 --- a/Backend/Endpoints/AdminEndpoints.cs +++ b/Backend/Endpoints/AdminEndpoints.cs @@ -13,6 +13,7 @@ public static class AdminEndpoints group.MapAdminDashboardEndpoints(); group.MapAdminSeasonManagementEndpoints(); + group.MapAdminArchiveEndpoints(); group.MapAdminModerationEndpoints(); group.MapAdminExtrasEndpoints(); group.MapAdminTeamEndpoints(); diff --git a/Backend/Endpoints/AdminNominationModerationEndpoints.cs b/Backend/Endpoints/AdminNominationModerationEndpoints.cs index f930277..ea40d6e 100644 --- a/Backend/Endpoints/AdminNominationModerationEndpoints.cs +++ b/Backend/Endpoints/AdminNominationModerationEndpoints.cs @@ -28,13 +28,6 @@ public static partial class AdminModerationEndpoints return Results.NotFound(); } - var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson); - if (trackingFlags.Any(flag => flag.BlocksApproval) - && !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase)) - { - return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." }); - } - var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel); if (string.IsNullOrWhiteSpace(rawDisplayName)) diff --git a/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs index f90d956..355ea23 100644 --- a/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs @@ -193,6 +193,34 @@ public static partial class AdminSeasonManagementEndpoints return Results.Ok(new { saved = true, candidateId = candidate.Id }); } + private static async Task GetCandidateDeletePreview( + int candidateId, + AwardsDbContext db, + CancellationToken cancellationToken) + { + var candidate = await db.Candidates + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == candidateId, cancellationToken); + if (candidate is null) + { + return Results.NotFound(); + } + + var nominationCount = await db.Nominations.CountAsync(item => item.CandidateId == candidateId, cancellationToken); + var clipCount = await db.ClipSubmissions.CountAsync(item => item.CandidateId == candidateId, cancellationToken); + var voteCount = await db.VoteEntries.CountAsync(item => item.CandidateId == candidateId, cancellationToken); + var resultCount = await db.Results.CountAsync(item => item.CandidateId == candidateId, cancellationToken); + + return Results.Ok(new + { + candidateId, + nominationCount, + clipCount, + voteCount, + resultCount, + }); + } + private static async Task DeleteCandidate( HttpContext context, int candidateId, @@ -206,6 +234,38 @@ public static partial class AdminSeasonManagementEndpoints return Results.NotFound(); } + var linkedNominations = await db.Nominations + .Where(item => item.CandidateId == candidateId) + .ToListAsync(context.RequestAborted); + if (linkedNominations.Count > 0) + { + db.Nominations.RemoveRange(linkedNominations); + } + + var linkedClips = await db.ClipSubmissions + .Where(item => item.CandidateId == candidateId) + .ToListAsync(context.RequestAborted); + if (linkedClips.Count > 0) + { + db.ClipSubmissions.RemoveRange(linkedClips); + } + + var linkedVoteEntries = await db.VoteEntries + .Where(item => item.CandidateId == candidateId) + .ToListAsync(context.RequestAborted); + if (linkedVoteEntries.Count > 0) + { + db.VoteEntries.RemoveRange(linkedVoteEntries); + } + + var linkedResults = await db.Results + .Where(item => item.CandidateId == candidateId) + .ToListAsync(context.RequestAborted); + if (linkedResults.Count > 0) + { + db.Results.RemoveRange(linkedResults); + } + db.Candidates.Remove(candidate); adminAuditService.AddEntry( session.TwitchUserId, @@ -213,11 +273,29 @@ public static partial class AdminSeasonManagementEndpoints "candidate", candidate.Id.ToString(), $"Kandidat {candidate.DisplayName} wurde gelöscht.", - new { candidate.CategoryId, candidate.Platform }, + new + { + candidate.CategoryId, + candidate.Platform, + deletedNominations = linkedNominations.Count, + deletedClips = linkedClips.Count, + deletedVoteEntries = linkedVoteEntries.Count, + deletedResults = linkedResults.Count, + }, RequestMetadataReader.Read(context)); - await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(new { deleted = true, candidateId }); + try + { + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, candidateId }); + } + catch (DbUpdateException) + { + return Results.BadRequest(new + { + message = "Kandidat konnte nicht gelöscht werden, weil noch verknüpfte Daten blockieren.", + }); + } } private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection allowedValues) diff --git a/Backend/Endpoints/AdminSeasonDetailEndpoints.cs b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs index 37c68ab..0388b41 100644 --- a/Backend/Endpoints/AdminSeasonDetailEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs @@ -23,11 +23,11 @@ public static partial class AdminSeasonManagementEndpoints .FirstOrDefaultAsync(item => item.Id == 1); var trackingRules = TrackingRulesSettings.Read(settings); - var candidates = await db.Candidates + var candidateRows = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == seasonId) .OrderBy(item => item.DisplayName) - .Select(item => new AdminCandidateItemDto( + .Select(item => new AdminCandidateRow( item.Id, item.CategoryId, item.StreamerIdentityId, @@ -43,7 +43,7 @@ public static partial class AdminSeasonManagementEndpoints item.ClipEmbedStatus)) .ToArrayAsync(); - var candidateCounts = candidates + var candidateCounts = candidateRows .GroupBy(item => item.CategoryId) .ToDictionary(grouping => grouping.Key, grouping => grouping.Count()); @@ -217,6 +217,11 @@ public static partial class AdminSeasonManagementEndpoints item.CategoryId, item.CandidateId)) .ToArrayAsync(); + var candidates = BuildCandidateItems( + candidateRows, + pendingNominationRows, + reviewedNominationRows, + votingEntryRows); var votingWorkspace = BuildVotingWorkspace( categories, candidates, @@ -281,6 +286,21 @@ public static partial class AdminSeasonManagementEndpoints int CategoryId, int CandidateId); + private sealed record AdminCandidateRow( + int Id, + int CategoryId, + int? StreamerIdentityId, + string DisplayName, + string ChannelSlug, + string Platform, + int NominationTally, + string AcceptanceStatus, + string? AcceptanceNote, + string? ClipCompilationUrl, + string? ClipCompilationTitle, + string? ClipCompilationPlatform, + string ClipEmbedStatus); + private sealed record AdminNominationRow( int Id, int? CategoryId, @@ -314,6 +334,79 @@ public static partial class AdminSeasonManagementEndpoints string? ReviewedByTwitchId, DateTimeOffset? ReviewedAt); + private static AdminCandidateItemDto[] BuildCandidateItems( + AdminCandidateRow[] candidateRows, + AdminNominationRow[] pendingNominationRows, + AdminNominationRow[] reviewedNominationRows, + AdminVotingEntryRow[] votingEntryRows) + { + var allNominationRows = pendingNominationRows + .Concat(reviewedNominationRows) + .ToArray(); + var voteCountByCandidate = votingEntryRows + .GroupBy(item => item.CandidateId) + .ToDictionary(group => group.Key, group => group.Count()); + + return candidateRows + .Select(item => new AdminCandidateItemDto( + item.Id, + item.CategoryId, + item.StreamerIdentityId, + item.DisplayName, + item.ChannelSlug, + item.Platform, + ResolveCandidateAvgViewers(item, allNominationRows), + voteCountByCandidate.GetValueOrDefault(item.Id, 0), + item.NominationTally, + item.AcceptanceStatus, + item.AcceptanceNote, + item.ClipCompilationUrl, + item.ClipCompilationTitle, + item.ClipCompilationPlatform, + item.ClipEmbedStatus)) + .ToArray(); + } + + private static int? ResolveCandidateAvgViewers(AdminCandidateRow candidate, AdminNominationRow[] nominationRows) + { + var directMatch = nominationRows + .Where(item => item.CandidateId == candidate.Id && item.AvgViewers.HasValue) + .OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt) + .Select(item => item.AvgViewers) + .FirstOrDefault(); + if (directMatch.HasValue) + { + return directMatch.Value; + } + + if (candidate.StreamerIdentityId.HasValue) + { + var identityMatch = nominationRows + .Where(item => item.StreamerIdentityId == candidate.StreamerIdentityId && item.AvgViewers.HasValue) + .OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt) + .Select(item => item.AvgViewers) + .FirstOrDefault(); + if (identityMatch.HasValue) + { + return identityMatch.Value; + } + } + + var normalizedChannel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalizedChannel)) + { + return null; + } + + return nominationRows + .Where(item => + item.AvgViewers.HasValue + && string.Equals(item.ResolvedChannel?.Trim().TrimStart('@'), normalizedChannel, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt) + .Select(item => item.AvgViewers) + .FirstOrDefault(); + } + private static AdminNominationReviewItemDto ToNominationReviewItem( AdminNominationRow item, IEnumerable categoryRows, diff --git a/Backend/Endpoints/AdminSeasonManagementEndpoints.cs b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs index 4c88e8b..1709961 100644 --- a/Backend/Endpoints/AdminSeasonManagementEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs @@ -60,6 +60,10 @@ public static partial class AdminSeasonManagementEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates)) .WithName("UpdateAdminCandidate") .WithOpenApi(); + group.MapGet("/candidates/{candidateId:int}/delete-preview", GetCandidateDeletePreview) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates)) + .WithName("GetAdminCandidateDeletePreview") + .WithOpenApi(); group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates)) .WithName("DeleteAdminCandidate") diff --git a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs index 75de94e..06cba79 100644 --- a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs +++ b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs @@ -14,6 +14,15 @@ public static class AdminSiteSettingsEndpoints private const string FallbackMaintenanceTitle = "Sternenpause"; private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."; private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen."; + private const string DefaultHostImageUrl = "/assets/amaterasu2sei_2.png"; + private const int MaxHostImageBytes = 8 * 1024 * 1024; + + private static readonly IReadOnlyDictionary AllowedHostImageContentTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/webp"] = ".webp", + }; public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group) { @@ -25,6 +34,10 @@ public static class AdminSiteSettingsEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content)) .WithName("UpdateAdminSiteSettings") .WithOpenApi(); + group.MapPost("/site-settings/host-image", UploadHostImage) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content)) + .WithName("UploadAdminHostImage") + .WithOpenApi(); group.MapGet("/operational-settings", GetOperationalSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("GetAdminOperationalSettings") @@ -68,43 +81,76 @@ public static class AdminSiteSettingsEndpoints return Results.NotFound(); } - return Results.Ok(new AdminSiteSettingsResponse( - settings.HostDisplayName, - settings.HostTagline, - settings.NewsletterUrl, - settings.ShareXUrl, - settings.ShareDiscordUrl, - settings.PrivacyEmail, - settings.PrivacyPolicyContent, - settings.PrivacyPolicyUpdatedBy, - settings.PrivacyPolicyUpdatedAt, - settings.ImprintUrl, - settings.ImprintContent, - settings.ContactUrl, - settings.ContactContent, - settings.SponsorsUrl, - settings.SponsorsContent, - settings.ShowactsUrl, - settings.ShowactsContent, - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow), - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle), - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText), - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel), - settings.StreamBannerLiveButtonUrl, - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel), - settings.StreamBannerUseCompletedContent, - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow), - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle), - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText), - SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel), - settings.StreamBannerCompletedButtonUrl, - SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle), - SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription), - SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle), - SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription), - SeasonMappings.ReadSocialLinks(settings), - SeasonMappings.ReadFaqItems(settings), - settings.ShowactFormSchemaJson ?? "[]")); + return Results.Ok(MapSiteSettingsResponse(settings)); + } + + private static async Task UploadHostImage( + HttpContext context, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + if (!context.Request.HasFormContentType) + { + return Results.BadRequest(new { message = "Bitte ein Bild als Formular-Upload senden." }); + } + + var form = await context.Request.ReadFormAsync(context.RequestAborted); + var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault(); + if (file is null || file.Length == 0) + { + return Results.BadRequest(new { message = "Bitte ein Hostbild auswählen." }); + } + + if (file.Length > MaxHostImageBytes) + { + return Results.BadRequest(new { message = "Hostbild ist zu groß. Maximal erlaubt sind 8 MB." }); + } + + if (!AllowedHostImageContentTypes.TryGetValue(file.ContentType, out var expectedExtension)) + { + return Results.BadRequest(new { message = "Bitte PNG, JPG oder WebP hochladen." }); + } + + var extension = Path.GetExtension(file.FileName); + if (!string.IsNullOrWhiteSpace(extension) + && !string.Equals(extension, expectedExtension, StringComparison.OrdinalIgnoreCase) + && !(string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase) + && string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))) + { + return Results.BadRequest(new { message = "Dateiendung und Bildtyp passen nicht zusammen." }); + } + + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + await using var stream = file.OpenReadStream(); + using var memory = new MemoryStream((int)file.Length); + await stream.CopyToAsync(memory, context.RequestAborted); + + settings.HostImageData = memory.ToArray(); + settings.HostImageContentType = file.ContentType; + settings.HostImageUpdatedAt = DateTimeOffset.UtcNow; + + var session = AdminEndpointConventions.CurrentSession(context); + adminAuditService.AddEntry( + session.TwitchUserId, + "site-settings.host-image.upload", + "site-settings", + settings.Id.ToString(), + "Landingpage-Hostbild wurde aktualisiert.", + new + { + fileName = file.FileName, + fileSize = file.Length, + file.ContentType, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(MapSiteSettingsResponse(settings)); } private static async Task UpdateSiteSettings( @@ -129,6 +175,7 @@ public static class AdminSiteSettingsEndpoints settings.HostDisplayName = request.HostDisplayName.Trim(); settings.HostTagline = request.HostTagline.Trim(); + settings.HostArtistName = request.HostArtistName.Trim(); settings.NewsletterUrl = normalizedUrls.NewsletterUrl; settings.ShareXUrl = normalizedUrls.ShareXUrl; settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl; @@ -189,6 +236,61 @@ public static class AdminSiteSettingsEndpoints return Results.Ok(new { saved = true }); } + private static AdminSiteSettingsResponse MapSiteSettingsResponse(SiteSettings settings) + { + return new AdminSiteSettingsResponse( + settings.HostDisplayName, + settings.HostTagline, + settings.HostArtistName, + BuildHostImageUrl(settings), + settings.NewsletterUrl, + settings.ShareXUrl, + settings.ShareDiscordUrl, + settings.PrivacyEmail, + settings.PrivacyPolicyContent, + settings.PrivacyPolicyUpdatedBy, + settings.PrivacyPolicyUpdatedAt, + settings.ImprintUrl, + settings.ImprintContent, + settings.ContactUrl, + settings.ContactContent, + settings.SponsorsUrl, + settings.SponsorsContent, + settings.ShowactsUrl, + settings.ShowactsContent, + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow), + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle), + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText), + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel), + settings.StreamBannerLiveButtonUrl, + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel), + settings.StreamBannerUseCompletedContent, + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow), + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle), + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText), + SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel), + settings.StreamBannerCompletedButtonUrl, + SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle), + SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription), + SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle), + SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription), + SeasonMappings.ReadSocialLinks(settings), + SeasonMappings.ReadFaqItems(settings), + settings.ShowactFormSchemaJson ?? "[]"); + } + + internal static string BuildHostImageUrl(SiteSettings settings) + { + if (settings.HostImageData is not { Length: > 0 }) + { + return DefaultHostImageUrl; + } + + var version = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) + ?? settings.HostImageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture); + return $"/api/public/host-image?v={version}"; + } + private static IResult? NormalizeSiteSettingsUrls( UpdateSiteSettingsRequest request, out PublicSiteUrlSettings normalizedUrls, diff --git a/Backend/Endpoints/PublicEndpoints.cs b/Backend/Endpoints/PublicEndpoints.cs index 01c0375..7de0851 100644 --- a/Backend/Endpoints/PublicEndpoints.cs +++ b/Backend/Endpoints/PublicEndpoints.cs @@ -14,6 +14,10 @@ public static partial class PublicEndpoints .WithName("GetSiteStatus") .WithOpenApi(); + group.MapGet("/host-image", GetHostImage) + .WithName("GetPublicHostImage") + .WithOpenApi(); + group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories) .WithName("GetSeasonCategories") .WithOpenApi(); diff --git a/Backend/Endpoints/PublicHostImageEndpoints.cs b/Backend/Endpoints/PublicHostImageEndpoints.cs new file mode 100644 index 0000000..2e5b5a7 --- /dev/null +++ b/Backend/Endpoints/PublicHostImageEndpoints.cs @@ -0,0 +1,35 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task GetHostImage(AwardsDbContext db) + { + var settings = await db.SiteSettings + .AsNoTracking() + .Where(item => item.Id == 1) + .Select(item => new + { + item.HostImageData, + item.HostImageContentType, + item.HostImageUpdatedAt, + }) + .FirstOrDefaultAsync(); + + if (settings?.HostImageData is not { Length: > 0 } imageData + || string.IsNullOrWhiteSpace(settings.HostImageContentType)) + { + return Results.NotFound(); + } + + var entityTag = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) + ?? imageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture); + return Results.File( + imageData, + settings.HostImageContentType, + entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"host-{entityTag}\""), + lastModified: settings.HostImageUpdatedAt); + } +} diff --git a/Backend/Endpoints/PublicOverviewEndpoints.cs b/Backend/Endpoints/PublicOverviewEndpoints.cs index 2abfed2..643ec79 100644 --- a/Backend/Endpoints/PublicOverviewEndpoints.cs +++ b/Backend/Endpoints/PublicOverviewEndpoints.cs @@ -89,8 +89,23 @@ public static partial class PublicEndpoints .OrderByDescending(item => item.Year) .ToArrayAsync(); + var archivedWinnerYearRows = await db.ArchivedWinners + .AsNoTracking() + .Where(item => latestPublishedWinnerYear == null || item.Year < latestPublishedWinnerYear.Value) + .GroupBy(item => item.Year) + .Select(group => new + { + Year = group.Key, + WinnerCount = group.Count(), + }) + .ToArrayAsync(); + var archiveYears = archiveYearRows .Select(item => new ArchiveYearDto(item.Year, item.WinnerCount)) + .Concat(archivedWinnerYearRows.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))) + .GroupBy(item => item.Year) + .Select(group => group.Last()) + .OrderByDescending(item => item.Year) .ToArray(); var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase); @@ -150,6 +165,8 @@ public static partial class PublicEndpoints new PublicSiteContentDto( siteSettings.HostDisplayName, siteSettings.HostTagline, + siteSettings.HostArtistName, + AdminSiteSettingsEndpoints.BuildHostImageUrl(siteSettings), siteSettings.NewsletterUrl, siteSettings.ShareXUrl, siteSettings.ShareDiscordUrl, diff --git a/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs index 3664048..8b71fe0 100644 --- a/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs +++ b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs @@ -9,6 +9,42 @@ public static partial class PublicEndpoints { private static async Task GetWinnerArchive(int year, AwardsDbContext db) { + var latestPublishedWinnerYear = await db.Results + .AsNoTracking() + .Where(result => result.Season.WinnersPublishedAt != null) + .Select(result => (int?)result.Season.Year) + .MaxAsync(); + + var archivedWinnerRows = await db.ArchivedWinners + .AsNoTracking() + .Where(item => item.Year == year) + .OrderBy(item => item.Category) + .ThenBy(item => item.Subcategory) + .ThenBy(item => item.WinnerName) + .ToArrayAsync(); + if (archivedWinnerRows.Length > 0) + { + var archivedItems = archivedWinnerRows + .Select(item => + { + var metadata = SeasonMappings.InferProfileMetadataFromUrl(item.WinnerUrl, item.WinnerName); + return new WinnerArchiveItemDto( + item.Subcategory, + item.Category, + item.WinnerName, + metadata.Slug, + metadata.Platform, + item.WinnerUrl, + null, + null, + null, + null); + }) + .ToArray(); + + return Results.Ok(new WinnerArchiveResponse(year, archivedItems)); + } + var season = await db.Seasons .AsNoTracking() .Where(item => item.Year == year) @@ -16,7 +52,7 @@ public static partial class PublicEndpoints .FirstOrDefaultAsync(); if (season is null) { - return Results.NotFound(); + return Results.Ok(new WinnerArchiveResponse(year, [])); } if (season.WinnersPublishedAt is null) @@ -24,11 +60,6 @@ public static partial class PublicEndpoints return Results.Ok(new WinnerArchiveResponse(year, [])); } - var latestPublishedWinnerYear = await db.Results - .AsNoTracking() - .Where(result => result.Season.WinnersPublishedAt != null) - .Select(result => (int?)result.Season.Year) - .MaxAsync(); if (latestPublishedWinnerYear == season.Year) { return Results.Ok(new WinnerArchiveResponse(year, [])); diff --git a/Backend/Migrations/20260629181644_AddArchivedWinners.Designer.cs b/Backend/Migrations/20260629181644_AddArchivedWinners.Designer.cs new file mode 100644 index 0000000..c39e876 --- /dev/null +++ b/Backend/Migrations/20260629181644_AddArchivedWinners.Designer.cs @@ -0,0 +1,1605 @@ +// +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("20260629181644_AddArchivedWinners")] + partial class AddArchivedWinners + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.ArchivedWinner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinnerName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WinnerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Category", "Subcategory") + .IsUnique(); + + b.ToTable("ArchivedWinners"); + }); + + 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"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NominationTally") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.HasIndex("StreamerIdentityId"); + + b.ToTable("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgViewers") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryGroupName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasDefaultValue(""); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowersGained") + .HasColumnType("integer"); + + b.Property("HoursStreamed") + .HasColumnType("integer"); + + b.Property("HoursWatched") + .HasColumnType("integer"); + + b.Property("PeakViewers") + .HasColumnType("integer"); + + b.Property("ResolvedChannel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResolvedPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SuggestedCategoryId") + .HasColumnType("integer"); + + b.Property("TrackerCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackerStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("pending"); + + b.Property("TrackingFlagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TrackingReviewNote") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TrackingReviewStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("clear"); + + b.Property("TrackingReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackingReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("StreamerIdentityId"); + + b.HasIndex("SuggestedCategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.HasIndex("SeasonId", "CategoryGroupName", "Status"); + + b.HasIndex("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + + b.ToTable("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("IsDemo") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("WinnersPublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinnersPublishedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwardsSectionDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("AwardsSectionTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationEndsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationStartsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("StreamBannerCompletedButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerCompletedButtonUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("StreamBannerCompletedEyebrow") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerCompletedText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamBannerCompletedTitle") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("StreamBannerEyebrow") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerLiveButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerLiveButtonUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("StreamBannerLockedButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamBannerTitle") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("StreamBannerUseCompletedContent") + .HasColumnType("boolean"); + + b.Property("SubcategoriesSectionDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubcategoriesSectionTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingReviewNotes") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ViewerStatsProviderBaseUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedKey") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ProfileUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedKey") + .IsUnique(); + + b.ToTable("StreamerIdentities"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + }); + + 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"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Candidates") + .HasForeignKey("StreamerIdentityId"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", null) + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Nominations") + .HasForeignKey("StreamerIdentityId"); + + b.HasOne("Backend.Domain.Category", "SuggestedCategory") + .WithMany() + .HasForeignKey("SuggestedCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + + b.Navigation("SuggestedCategory"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Navigation("Candidates"); + + b.Navigation("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260629181644_AddArchivedWinners.cs b/Backend/Migrations/20260629181644_AddArchivedWinners.cs new file mode 100644 index 0000000..c30f4a9 --- /dev/null +++ b/Backend/Migrations/20260629181644_AddArchivedWinners.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddArchivedWinners : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArchivedWinners", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Year = table.Column(type: "integer", nullable: false), + Category = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Subcategory = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + WinnerName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + WinnerUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ArchivedWinners", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArchivedWinners_Year_Category_Subcategory", + table: "ArchivedWinners", + columns: new[] { "Year", "Category", "Subcategory" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchivedWinners"); + } + } +} diff --git a/Backend/Migrations/20260629191805_SeedDemoSponsorsAndShareLinks.Designer.cs b/Backend/Migrations/20260629191805_SeedDemoSponsorsAndShareLinks.Designer.cs new file mode 100644 index 0000000..96741b7 --- /dev/null +++ b/Backend/Migrations/20260629191805_SeedDemoSponsorsAndShareLinks.Designer.cs @@ -0,0 +1,1620 @@ +// +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("20260629191805_SeedDemoSponsorsAndShareLinks")] + partial class SeedDemoSponsorsAndShareLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.ArchivedWinner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinnerName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WinnerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Category", "Subcategory") + .IsUnique(); + + b.ToTable("ArchivedWinners"); + }); + + 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"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NominationTally") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.HasIndex("StreamerIdentityId"); + + b.ToTable("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgViewers") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryGroupName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasDefaultValue(""); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowersGained") + .HasColumnType("integer"); + + b.Property("HoursStreamed") + .HasColumnType("integer"); + + b.Property("HoursWatched") + .HasColumnType("integer"); + + b.Property("PeakViewers") + .HasColumnType("integer"); + + b.Property("ResolvedChannel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResolvedPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SuggestedCategoryId") + .HasColumnType("integer"); + + b.Property("TrackerCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackerStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("pending"); + + b.Property("TrackingFlagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TrackingReviewNote") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TrackingReviewStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("clear"); + + b.Property("TrackingReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackingReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("StreamerIdentityId"); + + b.HasIndex("SuggestedCategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.HasIndex("SeasonId", "CategoryGroupName", "Status"); + + b.HasIndex("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + + b.ToTable("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("IsDemo") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("WinnersPublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinnersPublishedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwardsSectionDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("AwardsSectionTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostImageContentType") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("HostImageData") + .HasColumnType("bytea"); + + b.Property("HostImageUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationEndsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationStartsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("StreamBannerCompletedButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerCompletedButtonUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("StreamBannerCompletedEyebrow") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerCompletedText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamBannerCompletedTitle") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("StreamBannerEyebrow") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerLiveButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerLiveButtonUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("StreamBannerLockedButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamBannerTitle") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("StreamBannerUseCompletedContent") + .HasColumnType("boolean"); + + b.Property("SubcategoriesSectionDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubcategoriesSectionTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingReviewNotes") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ViewerStatsProviderBaseUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedKey") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ProfileUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedKey") + .IsUnique(); + + b.ToTable("StreamerIdentities"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + }); + + 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"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Candidates") + .HasForeignKey("StreamerIdentityId"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", null) + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Nominations") + .HasForeignKey("StreamerIdentityId"); + + b.HasOne("Backend.Domain.Category", "SuggestedCategory") + .WithMany() + .HasForeignKey("SuggestedCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + + b.Navigation("SuggestedCategory"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Navigation("Candidates"); + + b.Navigation("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260629191805_SeedDemoSponsorsAndShareLinks.cs b/Backend/Migrations/20260629191805_SeedDemoSponsorsAndShareLinks.cs new file mode 100644 index 0000000..9e4659a --- /dev/null +++ b/Backend/Migrations/20260629191805_SeedDemoSponsorsAndShareLinks.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class SeedDemoSponsorsAndShareLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE "SiteSettings" + SET + "ShareXUrl" = CASE + WHEN COALESCE(NULLIF("ShareXUrl", ''), '') IN ('', 'https://x.com/intent/tweet') + THEN 'https://x.com/intent/tweet?text=Schaut%20euch%20die%20VTuber%20Star%20Awards%20an!&url=https%3A%2F%2Faward.noveria.net' + ELSE "ShareXUrl" + END, + "ShareDiscordUrl" = CASE + WHEN COALESCE(NULLIF("ShareDiscordUrl", ''), '') IN ('', 'https://discord.gg/') + THEN 'https://discord.gg/jayuhime' + ELSE "ShareDiscordUrl" + END + WHERE "Id" = 1; + + INSERT INTO "Sponsors" ( + "Id", "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt" + ) + SELECT seed."Id", seed."SeasonId", seed."Name", seed."WebsiteUrl", seed."LogoUrl", seed."Description", seed."Tier", seed."SortOrder", seed."IsVisible", seed."CreatedAt", seed."UpdatedAt" + FROM ( + VALUES + (9006, 1001, 'Starfall Merch Lab', 'https://example.invalid/starfall-merch', '/demo/sponsors/chibicanvas-market.svg', 'Merch-Pakete, Sticker-Bundles und kleine Giveaways fuer Community-Aktionen rund um die Show.', 'Merch Partner', 60, TRUE, TIMESTAMPTZ '2026-06-01 10:25:00+00', NULL::timestamptz), + (9007, 1001, 'Moonframe Media', 'https://example.invalid/moonframe-media', '/demo/sponsors/prismloop-audio.svg', 'Highlight-Cuts, Social-Assets und kurze Promo-Snippets fuer Voting- und Finale-Phasen.', 'Media Partner', 70, TRUE, TIMESTAMPTZ '2026-06-01 10:30:00+00', NULL::timestamptz), + (9008, 1001, 'PixelHarbor Tools', 'https://example.invalid/pixelharbor-tools', '/demo/sponsors/cloudbeacon-hosting.svg', 'Kleine Creator-Tools fuer Landingpages, Formulare und Community-Orga im Eventbetrieb.', 'Tooling Partner', 80, TRUE, TIMESTAMPTZ '2026-06-01 10:35:00+00', NULL::timestamptz) + ) AS seed("Id", "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt") + WHERE EXISTS (SELECT 1 FROM "Seasons" WHERE "Id" = seed."SeasonId") + AND NOT EXISTS ( + SELECT 1 + FROM "Sponsors" existing + WHERE existing."SeasonId" = seed."SeasonId" + AND existing."Name" = seed."Name" + ); + + SELECT setval(pg_get_serial_sequence('"Sponsors"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Sponsors"), 1)); + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DELETE FROM "Sponsors" + WHERE "Id" IN (9006, 9007, 9008) + AND "SeasonId" = 1001; + + UPDATE "SiteSettings" + SET + "ShareXUrl" = 'https://x.com/intent/tweet', + "ShareDiscordUrl" = 'https://discord.gg/' + WHERE "Id" = 1 + AND "ShareXUrl" = 'https://x.com/intent/tweet?text=Schaut%20euch%20die%20VTuber%20Star%20Awards%20an!&url=https%3A%2F%2Faward.noveria.net' + AND "ShareDiscordUrl" = 'https://discord.gg/jayuhime'; + """ + ); + } + } +} diff --git a/Backend/Migrations/20260629192220_SeedExampleSponsorsForCurrentSeason.Designer.cs b/Backend/Migrations/20260629192220_SeedExampleSponsorsForCurrentSeason.Designer.cs new file mode 100644 index 0000000..60f98f9 --- /dev/null +++ b/Backend/Migrations/20260629192220_SeedExampleSponsorsForCurrentSeason.Designer.cs @@ -0,0 +1,1620 @@ +// +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("20260629192220_SeedExampleSponsorsForCurrentSeason")] + partial class SeedExampleSponsorsForCurrentSeason + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.ArchivedWinner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinnerName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WinnerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Category", "Subcategory") + .IsUnique(); + + b.ToTable("ArchivedWinners"); + }); + + 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"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NominationTally") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.HasIndex("StreamerIdentityId"); + + b.ToTable("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgViewers") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryGroupName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasDefaultValue(""); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowersGained") + .HasColumnType("integer"); + + b.Property("HoursStreamed") + .HasColumnType("integer"); + + b.Property("HoursWatched") + .HasColumnType("integer"); + + b.Property("PeakViewers") + .HasColumnType("integer"); + + b.Property("ResolvedChannel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResolvedPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SuggestedCategoryId") + .HasColumnType("integer"); + + b.Property("TrackerCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackerStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("pending"); + + b.Property("TrackingFlagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TrackingReviewNote") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TrackingReviewStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("clear"); + + b.Property("TrackingReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackingReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("StreamerIdentityId"); + + b.HasIndex("SuggestedCategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.HasIndex("SeasonId", "CategoryGroupName", "Status"); + + b.HasIndex("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + + b.ToTable("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("IsDemo") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("WinnersPublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinnersPublishedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwardsSectionDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("AwardsSectionTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostImageContentType") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("HostImageData") + .HasColumnType("bytea"); + + b.Property("HostImageUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationEndsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationStartsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("StreamBannerCompletedButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerCompletedButtonUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("StreamBannerCompletedEyebrow") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerCompletedText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamBannerCompletedTitle") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("StreamBannerEyebrow") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerLiveButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerLiveButtonUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("StreamBannerLockedButtonLabel") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StreamBannerText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamBannerTitle") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("StreamBannerUseCompletedContent") + .HasColumnType("boolean"); + + b.Property("SubcategoriesSectionDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubcategoriesSectionTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingReviewNotes") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ViewerStatsProviderBaseUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedKey") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ProfileUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedKey") + .IsUnique(); + + b.ToTable("StreamerIdentities"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + }); + + 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"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Candidates") + .HasForeignKey("StreamerIdentityId"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", null) + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Nominations") + .HasForeignKey("StreamerIdentityId"); + + b.HasOne("Backend.Domain.Category", "SuggestedCategory") + .WithMany() + .HasForeignKey("SuggestedCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + + b.Navigation("SuggestedCategory"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Navigation("Candidates"); + + b.Navigation("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260629192220_SeedExampleSponsorsForCurrentSeason.cs b/Backend/Migrations/20260629192220_SeedExampleSponsorsForCurrentSeason.cs new file mode 100644 index 0000000..64eb818 --- /dev/null +++ b/Backend/Migrations/20260629192220_SeedExampleSponsorsForCurrentSeason.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class SeedExampleSponsorsForCurrentSeason : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + WITH target_season AS ( + SELECT "Id" + FROM "Seasons" + WHERE "IsCurrent" = TRUE + ORDER BY "Year" DESC, "Id" DESC + LIMIT 1 + ) + INSERT INTO "Sponsors" ( + "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt" + ) + SELECT + target_season."Id", + seed."Name", + seed."WebsiteUrl", + seed."LogoUrl", + seed."Description", + seed."Tier", + seed."SortOrder", + TRUE, + TIMESTAMPTZ '2026-06-29 19:22:20+00', + NULL::timestamptz + FROM target_season + CROSS JOIN ( + VALUES + ('HoshiForge Studio', 'https://example.invalid/hoshiforge', '/demo/sponsors/hoshiforge-studio.svg', 'Branding-, Overlay- und Debuet-Visuals fuer VTuber-Projekte und Community-Events.', 'Presenting Sponsor', 10), + ('NekoPixel Energy', 'https://example.invalid/nekopixel', '/demo/sponsors/nekopixel-energy.svg', 'Community-fokussierter Drink-Partner fuer lange Showabende, Watchpartys und Creator-Collabs.', 'Gold Partner', 20), + ('PrismLoop Audio', 'https://example.invalid/prismloop', '/demo/sponsors/prismloop-audio.svg', 'Audio-Tools, Intro-Packs und Stream-Sounddesign fuer Live-Shows und Highlight-Clips.', 'Gold Partner', 30), + ('CloudBeacon Hosting', 'https://example.invalid/cloudbeacon', '/demo/sponsors/cloudbeacon-hosting.svg', 'Skalierbares Hosting fuer Voting, Landingpages und Event-Traffic rund um Showtage.', 'Tech Partner', 40), + ('ChibiCanvas Market', 'https://example.invalid/chibicanvas', '/demo/sponsors/chibicanvas-market.svg', 'Merch-, Sticker- und Artist-Marketplace mit Fokus auf VTuber, Emotes und Fanartikel.', 'Community Partner', 50) + ) AS seed("Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder") + WHERE NOT EXISTS ( + SELECT 1 + FROM "Sponsors" existing + WHERE existing."SeasonId" = target_season."Id" + ); + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DELETE FROM "Sponsors" + WHERE "Name" IN ( + 'HoshiForge Studio', + 'NekoPixel Energy', + 'PrismLoop Audio', + 'CloudBeacon Hosting', + 'ChibiCanvas Market' + ); + """ + ); + } + } +} diff --git a/Backend/Migrations/20260629194000_RemoveDemoSeasons.cs b/Backend/Migrations/20260629194000_RemoveDemoSeasons.cs new file mode 100644 index 0000000..468d01b --- /dev/null +++ b/Backend/Migrations/20260629194000_RemoveDemoSeasons.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations; + +[DbContext(typeof(Data.AwardsDbContext))] +[Migration("20260629194000_RemoveDemoSeasons")] +public partial class RemoveDemoSeasons : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE "RiskFlags" + SET "SeasonId" = NULL + WHERE "SeasonId" IN ( + SELECT "Id" + FROM "Seasons" + WHERE "IsDemo" = TRUE + ); + + DELETE FROM "Seasons" + WHERE "IsDemo" = TRUE; + """ + ); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } +} diff --git a/Backend/Migrations/20260629203000_AddHostImageUpload.cs b/Backend/Migrations/20260629203000_AddHostImageUpload.cs new file mode 100644 index 0000000..ba5677a --- /dev/null +++ b/Backend/Migrations/20260629203000_AddHostImageUpload.cs @@ -0,0 +1,38 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260629203000_AddHostImageUpload")] + /// + public partial class AddHostImageUpload : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageData" bytea; + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageContentType" character varying(80); + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageUpdatedAt" timestamp with time zone; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageUpdatedAt"; + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageContentType"; + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageData"; + """ + ); + } + } +} diff --git a/Backend/Migrations/20260629204500_AddHostArtistName.cs b/Backend/Migrations/20260629204500_AddHostArtistName.cs new file mode 100644 index 0000000..fd29c09 --- /dev/null +++ b/Backend/Migrations/20260629204500_AddHostArtistName.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("20260629204500_AddHostArtistName")] + /// + public partial class AddHostArtistName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostArtistName" character varying(120) NOT NULL DEFAULT ''; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostArtistName"; + """ + ); + } + } +} diff --git a/Backend/Migrations/20260629210000_EnsureRuntimeSchemaParity.cs b/Backend/Migrations/20260629210000_EnsureRuntimeSchemaParity.cs new file mode 100644 index 0000000..158e0ea --- /dev/null +++ b/Backend/Migrations/20260629210000_EnsureRuntimeSchemaParity.cs @@ -0,0 +1,38 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations; + +[DbContext(typeof(AwardsDbContext))] +[Migration("20260629210000_EnsureRuntimeSchemaParity")] +public partial class EnsureRuntimeSchemaParity : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageData" bytea; + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageContentType" character varying(80); + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageUpdatedAt" timestamp with time zone; + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostArtistName" character varying(120) NOT NULL DEFAULT ''; + ALTER TABLE "Nominations" ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300); + """ + ); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + ALTER TABLE "Nominations" DROP COLUMN IF EXISTS "StreamUrl"; + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostArtistName"; + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageUpdatedAt"; + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageContentType"; + ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageData"; + """ + ); + } +} diff --git a/Backend/Migrations/20260629213000_EnsureClipSubmissionsTable.cs b/Backend/Migrations/20260629213000_EnsureClipSubmissionsTable.cs new file mode 100644 index 0000000..67b934d --- /dev/null +++ b/Backend/Migrations/20260629213000_EnsureClipSubmissionsTable.cs @@ -0,0 +1,178 @@ +using Backend.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations; + +[DbContext(typeof(AwardsDbContext))] +[Migration("20260629213000_EnsureClipSubmissionsTable")] +public partial class EnsureClipSubmissionsTable : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE TABLE IF NOT EXISTS "ClipSubmissions" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "SeasonId" integer NOT NULL, + "CategoryId" integer, + "CandidateId" integer, + "SubmittedByTwitchId" character varying(120) NOT NULL, + "ClipUrl" character varying(500) NOT NULL, + "Title" character varying(200) NOT NULL, + "Creator" character varying(120) NOT NULL, + "Platform" character varying(40) NOT NULL, + "Status" character varying(20) NOT NULL, + "ReviewNote" character varying(500), + "ReviewedByTwitchId" character varying(120), + "CreatedFromIp" character varying(80) NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + "ReviewedAt" timestamp with time zone, + CONSTRAINT "PK_ClipSubmissions" PRIMARY KEY ("Id") + ); + + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "SeasonId" integer NOT NULL DEFAULT 0; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CategoryId" integer; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CandidateId" integer; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "SubmittedByTwitchId" character varying(120) NOT NULL DEFAULT ''; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ClipUrl" character varying(500) NOT NULL DEFAULT ''; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Title" character varying(200) NOT NULL DEFAULT ''; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Creator" character varying(120) NOT NULL DEFAULT ''; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Platform" character varying(40) NOT NULL DEFAULT ''; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending'; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500); + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120); + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT ''; + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CreatedAt" timestamp with time zone NOT NULL DEFAULT NOW(); + ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone; + + DO $vtsa_clip_constraints$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_ClipSubmissions_Seasons_SeasonId' + ) THEN + ALTER TABLE "ClipSubmissions" + ADD CONSTRAINT "FK_ClipSubmissions_Seasons_SeasonId" + FOREIGN KEY ("SeasonId") REFERENCES "Seasons"("Id") ON DELETE CASCADE; + END IF; + + 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; + $vtsa_clip_constraints$; + + CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId" + ON "ClipSubmissions" ("CandidateId"); + + CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status" + ON "ClipSubmissions" ("SeasonId", "Status"); + + DO $vtsa_clip_seed$ + DECLARE + target_season_id integer; + BEGIN + SELECT "Id" + INTO target_season_id + FROM "Seasons" + WHERE "IsCurrent" = TRUE + ORDER BY "Year" DESC + LIMIT 1; + + IF target_season_id IS NULL THEN + RETURN; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "ClipSubmissions" + WHERE "SeasonId" = target_season_id + ) THEN + RETURN; + END IF; + + INSERT INTO "ClipSubmissions" ( + "SeasonId", + "CategoryId", + "CandidateId", + "SubmittedByTwitchId", + "ClipUrl", + "Title", + "Creator", + "Platform", + "Status", + "ReviewNote", + "ReviewedByTwitchId", + "CreatedFromIp", + "CreatedAt", + "ReviewedAt" + ) + SELECT + target_season_id, + candidates."CategoryId", + candidates."Id", + seed_rows.submitted_by, + seed_rows.clip_url, + seed_rows.title, + seed_rows.creator, + seed_rows.platform, + seed_rows.status, + seed_rows.review_note, + seed_rows.reviewed_by, + '127.0.0.1', + seed_rows.created_at, + seed_rows.reviewed_at + FROM ( + VALUES + ('vtsa_demo_current_astra_shining_star_aster', 'viewer_2001', 'https://clips.twitch.tv/demo-nova-finale', 'Astra turns the boss fight', 'viewer_2001', 'Twitch', 'approved', 'Kontext passt.', 'clip_reviewer', TIMESTAMPTZ '2026-06-20 18:12:00+00', TIMESTAMPTZ '2026-06-21 09:00:00+00'), + ('vtsa_demo_current_melo_rising_star_aster', 'viewer_2002', 'https://youtu.be/demo-vera-stage', 'Melo sings the finale bridge', 'viewer_2002', 'YouTube', 'approved', NULL, 'clip_reviewer', TIMESTAMPTZ '2026-06-20 20:25:00+00', TIMESTAMPTZ '2026-06-21 09:08:00+00'), + ('vtsa_demo_current_lumi_shining_star_aster', 'viewer_2003', 'https://clips.twitch.tv/demo-ember-moment', 'Lumi opens the community event', 'viewer_2003', 'Twitch', 'pending', 'Timing pruefen.', NULL, TIMESTAMPTZ '2026-06-21 11:40:00+00', NULL), + ('vtsa_demo_current_velvet_shining_star_aster', 'viewer_2004', 'https://youtu.be/demo-chroma-design', 'Velvet explains the overlay rebuild', 'viewer_2004', 'YouTube', 'approved', NULL, 'clip_reviewer', TIMESTAMPTZ '2026-06-21 15:10:00+00', TIMESTAMPTZ '2026-06-22 08:45:00+00'), + ('vtsa_demo_current_lumi_rising_star_aster', 'viewer_2005', 'https://clips.twitch.tv/demo-sora-community', 'Lumi lets chat design the scene', 'viewer_2005', 'Twitch', 'pending', NULL, NULL, TIMESTAMPTZ '2026-06-22 19:55:00+00', NULL) + ) AS seed_rows( + channel_slug, + submitted_by, + clip_url, + title, + creator, + platform, + status, + review_note, + reviewed_by, + created_at, + reviewed_at + ) + INNER JOIN "Candidates" candidates + ON candidates."SeasonId" = target_season_id + AND lower(candidates."ChannelSlug") = lower(seed_rows.channel_slug); + + PERFORM setval( + pg_get_serial_sequence('"ClipSubmissions"', 'Id'), + COALESCE((SELECT MAX("Id") FROM "ClipSubmissions"), 1) + ); + END; + $vtsa_clip_seed$; + """ + ); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DELETE FROM "ClipSubmissions" + WHERE "SubmittedByTwitchId" IN ('viewer_2001', 'viewer_2002', 'viewer_2003', 'viewer_2004', 'viewer_2005'); + """ + ); + } +} diff --git a/Backend/Migrations/20260629220000_SeedDemoArchivedWinners2025.cs b/Backend/Migrations/20260629220000_SeedDemoArchivedWinners2025.cs new file mode 100644 index 0000000..3542a47 --- /dev/null +++ b/Backend/Migrations/20260629220000_SeedDemoArchivedWinners2025.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations; + +[DbContext(typeof(Data.AwardsDbContext))] +[Migration("20260629220000_SeedDemoArchivedWinners2025")] +public partial class SeedDemoArchivedWinners2025 : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DO $vtsa_archive_2025$ + BEGIN + IF EXISTS ( + SELECT 1 FROM "Seasons" + WHERE "Year" = 2025 AND "IsDemo" = FALSE + ) THEN + RAISE NOTICE 'Echte 2025-Season vorhanden; Demo-Archivgewinner werden nicht eingespielt.'; + RETURN; + END IF; + + INSERT INTO "ArchivedWinners" ( + "Year", "Category", "Subcategory", "WinnerName", "WinnerUrl", "CreatedAt" + ) + VALUES + (2025, 'Gaming', 'Hidden Star', 'Archiv Aster', 'https://twitch.tv/archiv_aster', TIMESTAMPTZ '2025-06-22 10:05:00+00'), + (2025, 'Gaming', 'Rising Star', 'Archiv Beryl', 'https://twitch.tv/archiv_beryl', TIMESTAMPTZ '2025-06-22 10:06:00+00'), + (2025, 'Gaming', 'Shining Star', 'Archiv Coda', 'https://clips.twitch.tv/demo-archiv-coda', TIMESTAMPTZ '2025-06-22 10:07:00+00'), + (2025, 'Music', 'Hidden Star', 'Archiv Drift', 'https://twitch.tv/archiv_drift', TIMESTAMPTZ '2025-06-22 10:08:00+00'), + (2025, 'Music', 'Rising Star', 'Archiv Elara', 'https://youtu.be/demo-archiv-elara', TIMESTAMPTZ '2025-06-22 10:09:00+00'), + (2025, 'Music', 'Shining Star', 'Archiv Finch', 'https://clips.twitch.tv/demo-archiv-finch', TIMESTAMPTZ '2025-06-22 10:10:00+00'), + (2025, 'Community', 'Hidden Star', 'Archiv Lyra', 'https://twitch.tv/archiv_lyra', TIMESTAMPTZ '2025-06-22 10:11:00+00'), + (2025, 'Community', 'Rising Star', 'Archiv Muse', 'https://youtu.be/demo-archiv-muse', TIMESTAMPTZ '2025-06-22 10:12:00+00'), + (2025, 'Community', 'Shining Star', 'Archiv Nia', 'https://clips.twitch.tv/demo-archiv-nia', TIMESTAMPTZ '2025-06-22 10:13:00+00') + ON CONFLICT ("Year", "Category", "Subcategory") DO NOTHING; + + PERFORM setval(pg_get_serial_sequence('"ArchivedWinners"', 'Id'), COALESCE((SELECT MAX("Id") FROM "ArchivedWinners"), 1)); + END; + $vtsa_archive_2025$; + """ + ); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DELETE FROM "ArchivedWinners" + WHERE "Year" = 2025 + AND "Category" IN ('Gaming', 'Music', 'Community') + AND "WinnerName" LIKE 'Archiv %'; + """ + ); + } +} diff --git a/Backend/Migrations/AwardsDbContextModelSnapshot.cs b/Backend/Migrations/AwardsDbContextModelSnapshot.cs index e9de46e..4a5fed3 100644 --- a/Backend/Migrations/AwardsDbContextModelSnapshot.cs +++ b/Backend/Migrations/AwardsDbContextModelSnapshot.cs @@ -77,6 +77,51 @@ namespace Backend.Migrations b.ToTable("AdminAuditEntries"); }); + modelBuilder.Entity("Backend.Domain.ArchivedWinner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinnerName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WinnerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Category", "Subcategory") + .IsUnique(); + + b.ToTable("ArchivedWinners"); + }); + modelBuilder.Entity("Backend.Domain.AwardResult", b => { b.Property("Id") @@ -779,11 +824,26 @@ namespace Backend.Migrations .IsRequired() .HasColumnType("text"); + b.Property("HostArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.Property("HostDisplayName") .IsRequired() .HasMaxLength(120) .HasColumnType("character varying(120)"); + b.Property("HostImageContentType") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("HostImageData") + .HasColumnType("bytea"); + + b.Property("HostImageUpdatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("HostTagline") .IsRequired() .HasMaxLength(160) diff --git a/Backend/Services/TrackingRulesSettings.cs b/Backend/Services/TrackingRulesSettings.cs index f9e4a89..1848a60 100644 --- a/Backend/Services/TrackingRulesSettings.cs +++ b/Backend/Services/TrackingRulesSettings.cs @@ -124,7 +124,7 @@ public static class TrackingRulesSettings new(FlagTrackerUnresolved, "Tracker-Link nicht aufloesbar", true, "high", "Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.", true, true, false, false), new(FlagUnsupportedPlatform, "Plattform nicht unterstuetzt", true, "medium", "Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.", true, true, false, false), new(FlagNoTrackerData, "Keine Tracker-Daten", true, "medium", "TwitchTracker hat keinen belastbaren Summary-Wert geliefert.", true, true, false, false), - new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, true, false), + new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, false, false), new(FlagManualReviewRequired, "Manuelle Pruefung noetig", true, "medium", "Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.", true, true, false, false), new(FlagLowConfidenceSmallChannel, "Low Confidence Small Channel", false, "low", "Kleine Kanaele koennen manuell tiefer geprueft werden.", false, true, false, false), new(FlagInsufficientActivityContext, "Zu wenig Aktivitaetskontext", false, "low", "Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.", false, true, false, false), @@ -206,7 +206,9 @@ public static class TrackingRulesSettings try { - return JsonSerializer.Deserialize(json, JsonOptions) ?? []; + return (JsonSerializer.Deserialize(json, JsonOptions) ?? []) + .Select(flag => flag with { BlocksApproval = false }) + .ToArray(); } catch (JsonException) { @@ -333,7 +335,7 @@ public static class TrackingRulesSettings Severity = severity, AutoTriggerEnabled = stored.AutoTriggerEnabled, RequiresManualReview = stored.RequiresManualReview, - BlocksApproval = stored.BlocksApproval, + BlocksApproval = false, AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride, }; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e67cb4..0796baf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -135,7 +135,7 @@ and smaller Vue files split by feature. | Data | Owner | Notes | | --- | --- | --- | -| Seasons, categories, candidates, winners | Backend | Public and admin views read from canonical backend state. | +| Seasons, categories, candidates, winners, archived winners | Backend | Public and admin views read from canonical backend state; historical archive winners are managed separately from current season winner publication. | | Nominations and clip submissions | Backend | Public writes are validated and reviewed through admin workflows. | | Site settings, landing content, footer links, showacts, sponsors | Backend | Content hub/admin settings own public presentation data. | | Team members, roles, permissions, sessions | Backend | UI may hide controls, but API authorization is authoritative. | diff --git a/frontend/public/assets/amaterasu2sei_2.png b/frontend/public/assets/amaterasu2sei_2.png new file mode 100644 index 0000000..76910de Binary files /dev/null and b/frontend/public/assets/amaterasu2sei_2.png differ diff --git a/frontend/src/components/admin/AdminCandidateDeleteModal.vue b/frontend/src/components/admin/AdminCandidateDeleteModal.vue index ee2c992..9a45a57 100644 --- a/frontend/src/components/admin/AdminCandidateDeleteModal.vue +++ b/frontend/src/components/admin/AdminCandidateDeleteModal.vue @@ -1,17 +1,49 @@