diff --git a/Backend/Contracts/AdminTeamContracts.cs b/Backend/Contracts/AdminTeamContracts.cs index 72a0848..ab7da89 100644 --- a/Backend/Contracts/AdminTeamContracts.cs +++ b/Backend/Contracts/AdminTeamContracts.cs @@ -17,6 +17,8 @@ public sealed record AdminTeamMemberDto( DateTimeOffset CreatedAt, DateTimeOffset? UpdatedAt, DateTimeOffset? LastLoginAt, + DateTimeOffset? LastOnlineAt, + bool IsOnline, DateTimeOffset? TwitchBoundAt, DateTimeOffset? PasswordResetAt); diff --git a/Backend/Contracts/AuthContracts.cs b/Backend/Contracts/AuthContracts.cs index be90dce..6efee9d 100644 --- a/Backend/Contracts/AuthContracts.cs +++ b/Backend/Contracts/AuthContracts.cs @@ -14,18 +14,10 @@ public sealed record TeamLoginRequest( string Login, string Password); -public sealed record TeamTwitchLoginRequest( - string TwitchUserId, - string? DisplayName); - public sealed record ChangePasswordRequest( string CurrentPassword, string NewPassword); -public sealed record BindTeamTwitchRequest( - string TwitchUserId, - string? DisplayName); - public sealed record TwitchAuthorizeRequest( string Purpose, string? ReturnUrl, diff --git a/Backend/Data/AwardsDbContext.cs b/Backend/Data/AwardsDbContext.cs index 38f4f85..c4d6293 100644 --- a/Backend/Data/AwardsDbContext.cs +++ b/Backend/Data/AwardsDbContext.cs @@ -103,6 +103,7 @@ public sealed class AwardsDbContext(DbContextOptions options) : modelBuilder.Entity(entity => { + entity.HasIndex(item => new { item.SeasonId, item.SubmittedByTwitchId }).IsUnique(); entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120); entity.Property(item => item.Status).HasMaxLength(30); }); diff --git a/Backend/Data/TeamAccountBootstrapper.cs b/Backend/Data/TeamAccountBootstrapper.cs index 1ecdc4f..c5061e5 100644 --- a/Backend/Data/TeamAccountBootstrapper.cs +++ b/Backend/Data/TeamAccountBootstrapper.cs @@ -17,7 +17,7 @@ public static class TeamAccountBootstrapper defaultLogin: "jayuhime", defaultDisplayName: "Jayuhime", role: AdminRoles.Owner, - fallbackPassword: configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]); + fallbackPassword: null); var creatorSeed = BuildSeed( configuration, @@ -33,6 +33,11 @@ public static class TeamAccountBootstrapper await EnsureMemberAsync(db, seed); } + await DeactivateDemoBackedSeedAccountAsync( + db, + ownerSeed, + configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]); + await db.SaveChangesAsync(); } @@ -80,6 +85,18 @@ public static class TeamAccountBootstrapper changed = true; } + if (member.CreatedByTwitchId == SeedActor + && (string.IsNullOrWhiteSpace(member.UpdatedByTwitchId) + || string.Equals(member.UpdatedByTwitchId, SeedActor, StringComparison.Ordinal)) + && (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal) + || !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal))) + { + member.PasswordHash = seed.Credentials.Value.Hash; + member.PasswordSalt = seed.Credentials.Value.Salt; + member.MustChangePassword = false; + changed = true; + } + if (changed) { member.UpdatedAt = DateTimeOffset.UtcNow; @@ -87,6 +104,26 @@ public static class TeamAccountBootstrapper } } + private static async Task DeactivateDemoBackedSeedAccountAsync(AwardsDbContext db, TeamAccountSeed seed, string? demoPassword) + { + if (seed.Credentials is not null || string.IsNullOrWhiteSpace(demoPassword)) + { + return; + } + + var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login); + if (member is null + || member.CreatedByTwitchId != SeedActor + || !DemoCredentialHasher.VerifyPassword(demoPassword, member.PasswordHash, member.PasswordSalt)) + { + return; + } + + member.IsActive = false; + member.UpdatedAt = DateTimeOffset.UtcNow; + member.UpdatedByTwitchId = SeedActor; + } + private static TeamAccountSeed BuildSeed( IConfiguration configuration, string sectionName, diff --git a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs index e211249..1db9dee 100644 --- a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs +++ b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs @@ -75,9 +75,15 @@ public static class AdminSiteSettingsEndpoints return Results.NotFound(); } + var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks); + if (urlValidationError is not null) + { + return urlValidationError; + } + settings.HostDisplayName = request.HostDisplayName.Trim(); settings.HostTagline = request.HostTagline.Trim(); - settings.NewsletterUrl = request.NewsletterUrl.Trim(); + settings.NewsletterUrl = normalizedUrls.NewsletterUrl; settings.PrivacyEmail = request.PrivacyEmail.Trim(); var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim(); var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal); @@ -88,13 +94,13 @@ public static class AdminSiteSettingsEndpoints settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow; } - settings.ImprintUrl = request.ImprintUrl.Trim(); + settings.ImprintUrl = normalizedUrls.ImprintUrl; settings.ImprintContent = request.ImprintContent.Trim(); - settings.ContactUrl = request.ContactUrl.Trim(); + settings.ContactUrl = normalizedUrls.ContactUrl; settings.ContactContent = request.ContactContent.Trim(); - settings.SponsorsUrl = request.SponsorsUrl.Trim(); + settings.SponsorsUrl = normalizedUrls.SponsorsUrl; settings.SponsorsContent = request.SponsorsContent.Trim(); - settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []); + settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks); settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []); adminAuditService.AddEntry( @@ -107,7 +113,7 @@ public static class AdminSiteSettingsEndpoints { settings.HostDisplayName, privacyChanged, - socialLinkCount = request.SocialLinks?.Length ?? 0, + socialLinkCount = socialLinks.Length, faqCount = request.Faq?.Length ?? 0, }, RequestMetadataReader.Read(context)); @@ -116,6 +122,73 @@ public static class AdminSiteSettingsEndpoints return Results.Ok(new { saved = true }); } + private static IResult? NormalizeSiteSettingsUrls( + UpdateSiteSettingsRequest request, + out PublicSiteUrlSettings normalizedUrls, + out PublicSocialLinkDto[] socialLinks) + { + normalizedUrls = new PublicSiteUrlSettings(); + socialLinks = []; + + if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage) + || !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage) + || !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage) + || !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)) + { + return Results.BadRequest(new { message = errorMessage }); + } + + normalizedUrls = new PublicSiteUrlSettings + { + NewsletterUrl = newsletterUrl, + ImprintUrl = imprintUrl, + ContactUrl = contactUrl, + SponsorsUrl = sponsorsUrl, + }; + + var normalizedSocialLinks = new List(); + foreach (var social in request.SocialLinks ?? []) + { + if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage)) + { + return Results.BadRequest(new { message = errorMessage }); + } + + normalizedSocialLinks.Add(social with { Url = normalizedUrl }); + } + + socialLinks = normalizedSocialLinks.ToArray(); + return null; + } + + private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage) + { + normalizedUrl = (value ?? string.Empty).Trim(); + errorMessage = string.Empty; + if (string.IsNullOrWhiteSpace(normalizedUrl)) + { + return true; + } + + if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + errorMessage = $"{fieldName} muss eine absolute http/https-URL sein."; + return false; + } + + normalizedUrl = uri.ToString(); + return true; + } + + private sealed class PublicSiteUrlSettings + { + public string NewsletterUrl { get; set; } = string.Empty; + public string ImprintUrl { get; set; } = string.Empty; + public string ContactUrl { get; set; } = string.Empty; + public string SponsorsUrl { get; set; } = string.Empty; + } + private static async Task GetOperationalSettings(AwardsDbContext db, IConfiguration configuration) { var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); diff --git a/Backend/Endpoints/AdminTeamEndpoints.cs b/Backend/Endpoints/AdminTeamEndpoints.cs index 007333a..1812255 100644 --- a/Backend/Endpoints/AdminTeamEndpoints.cs +++ b/Backend/Endpoints/AdminTeamEndpoints.cs @@ -13,6 +13,7 @@ namespace Backend.Endpoints; public static class AdminTeamEndpoints { private const int MinPasswordLength = 10; + private static readonly TimeSpan OnlineActivityWindow = TimeSpan.FromMinutes(5); private const string PersonalOwnerLogin = "jayuhime"; private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"]; @@ -79,11 +80,21 @@ public static class AdminTeamEndpoints .OrderByDescending(item => item.Role == AdminRoles.Owner) .ThenBy(item => item.Role) .ThenBy(item => item.DisplayName) - .Select(item => ToMemberDto(item)) .ToArrayAsync(); + var sessionKeys = members + .SelectMany(MemberSessionKeys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var sessions = sessionKeys.Length == 0 + ? [] + : await db.UserSessions + .AsNoTracking() + .Where(item => sessionKeys.Contains(item.TwitchUserId)) + .ToArrayAsync(); + var now = DateTimeOffset.UtcNow; return Results.Ok(new AdminTeamResponse( - members, + members.Select(member => ToMemberDto(member, sessions, now)), await BuildRoleDtosAsync(db), PermissionCatalog)); } @@ -322,10 +333,15 @@ public static class AdminTeamEndpoints IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); + if (!AdminRoles.IsPrivilegedFullControlRole(session.Role)) + { + return Results.Json(new { message = "Rollenberechtigungen können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden); + } var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted); + var roleChanges = new List(); foreach (var roleUpdate in request.Roles ?? []) { @@ -339,14 +355,18 @@ public static class AdminTeamEndpoints .Select(item => item.Trim()) .Where(item => allowedPermissions.Contains(item)) .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(item => item) .ToArray(); + permissionKeys = AdminPermissionCatalog.NormalizePermissionKeys(role, permissionKeys); if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length) { return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." }); } + var beforePermissions = existingRows.TryGetValue(role, out var existingRow) + ? AdminPermissionCatalog.NormalizePermissionKeys(role, ReadPermissionKeys(existingRow.PermissionsJson, AdminPermissionCatalog.DefaultPermissionKeys(role))) + : AdminPermissionCatalog.NormalizePermissionKeys(role, AdminPermissionCatalog.DefaultPermissionKeys(role)); + if (!existingRows.TryGetValue(role, out var row)) { row = new TeamRolePermission { Role = role }; @@ -356,6 +376,18 @@ public static class AdminTeamEndpoints row.PermissionsJson = JsonSerializer.Serialize(permissionKeys); row.UpdatedAt = DateTimeOffset.UtcNow; row.UpdatedByTwitchId = session.TwitchUserId; + + if (!beforePermissions.SequenceEqual(permissionKeys, StringComparer.OrdinalIgnoreCase)) + { + roleChanges.Add(new + { + role, + before = beforePermissions, + after = permissionKeys, + added = permissionKeys.Except(beforePermissions, StringComparer.OrdinalIgnoreCase).ToArray(), + removed = beforePermissions.Except(permissionKeys, StringComparer.OrdinalIgnoreCase).ToArray(), + }); + } } adminAuditService.AddEntry( @@ -364,7 +396,7 @@ public static class AdminTeamEndpoints "team-role", "matrix", "Team-Rollenberechtigungen wurden gespeichert.", - new { roleCount = request.Roles?.Length ?? 0 }, + new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); @@ -384,18 +416,30 @@ public static class AdminTeamEndpoints ? ReadPermissionKeys(json, role.PermissionKeys) : role.PermissionKeys; - if (AdminRoles.IsPrivilegedFullControlRole(role.Key)) - { - permissions = AdminPermissionCatalog.AllPermissionKeys; - } + permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions); return role with { PermissionKeys = permissions }; }) .ToArray(); } - private static AdminTeamMemberDto ToMemberDto(TeamMember member) => - new( + private static AdminTeamMemberDto ToMemberDto( + TeamMember member, + IReadOnlyCollection? sessions = null, + DateTimeOffset? now = null) + { + var memberSessions = sessions? + .Where(session => MemberSessionKeys(member).Contains(session.TwitchUserId, StringComparer.OrdinalIgnoreCase)) + .ToArray() ?? []; + var lastSeenAt = memberSessions + .Select(session => (DateTimeOffset?)session.LastSeenAt) + .Max() ?? member.LastLoginAt; + var isOnline = member.IsActive + && memberSessions.Any(session => + session.IsActive + && session.LastSeenAt >= (now ?? DateTimeOffset.UtcNow).Subtract(OnlineActivityWindow)); + + return new( member.Id, member.Login, member.DisplayName, @@ -407,8 +451,21 @@ public static class AdminTeamEndpoints member.CreatedAt, member.UpdatedAt, member.LastLoginAt, + lastSeenAt, + isOnline, member.TwitchBoundAt, member.PasswordResetAt); + } + + private static IEnumerable MemberSessionKeys(TeamMember member) + { + yield return $"team:{member.Login}"; + + if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId)) + { + yield return member.BoundTwitchUserId; + } + } private static IEnumerable ReadPermissionKeys(string json, IEnumerable fallback) { diff --git a/Backend/Endpoints/AuthEndpoints.cs b/Backend/Endpoints/AuthEndpoints.cs index 35b532e..d142b1e 100644 --- a/Backend/Endpoints/AuthEndpoints.cs +++ b/Backend/Endpoints/AuthEndpoints.cs @@ -23,21 +23,11 @@ public static partial class AuthEndpoints .WithName("TeamLogin") .WithOpenApi(); - group.MapPost("/team-twitch-login", TeamTwitchLogin) - .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) - .WithName("TeamTwitchLogin") - .WithOpenApi(); - group.MapPost("/password/change", ChangePassword) .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) .WithName("ChangePassword") .WithOpenApi(); - group.MapPost("/team/twitch-binding", BindTeamTwitch) - .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) - .WithName("BindTeamTwitch") - .WithOpenApi(); - group.MapPost("/twitch/authorize", StartTwitchAuthorization) .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) .WithName("StartTwitchAuthorization") diff --git a/Backend/Endpoints/AuthTeamLoginEndpoints.cs b/Backend/Endpoints/AuthTeamLoginEndpoints.cs index c96c808..a5b0550 100644 --- a/Backend/Endpoints/AuthTeamLoginEndpoints.cs +++ b/Backend/Endpoints/AuthTeamLoginEndpoints.cs @@ -47,38 +47,6 @@ public static partial class AuthEndpoints return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted)); } - private static async Task TeamTwitchLogin( - HttpContext context, - TeamTwitchLoginRequest request, - AwardsDbContext db, - IUserSessionService userSessionService) - { - var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId); - if (string.IsNullOrWhiteSpace(twitchUserId)) - { - return Results.Unauthorized(); - } - - var member = await db.TeamMembers.FirstOrDefaultAsync( - item => item.BoundTwitchUserId == twitchUserId, - context.RequestAborted); - if (member is null || !member.IsActive) - { - return Results.Unauthorized(); - } - - member.LastLoginAt = DateTimeOffset.UtcNow; - var session = await userSessionService.CreateSessionAsync( - twitchUserId, - member.DisplayName, - member.Role, - RequestMetadataReader.Read(context), - context.RequestAborted); - - await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted)); - } - private static async Task ChangePassword( HttpContext context, ChangePasswordRequest request, @@ -127,64 +95,6 @@ public static partial class AuthEndpoints return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted)); } - private static async Task BindTeamTwitch( - HttpContext context, - BindTeamTwitchRequest request, - AwardsDbContext db, - IUserSessionService userSessionService) - { - var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); - if (session is null) - { - return Results.Unauthorized(); - } - - var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted); - if (member is null || !member.IsActive) - { - return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." }); - } - - if (member.MustChangePassword) - { - return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." }); - } - - var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId); - var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName); - if (string.IsNullOrWhiteSpace(twitchUserId)) - { - return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." }); - } - - var alreadyBound = await db.TeamMembers.AnyAsync( - item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId, - context.RequestAborted); - if (alreadyBound) - { - return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." }); - } - - var previousTwitchUserId = member.BoundTwitchUserId; - if (!string.IsNullOrWhiteSpace(previousTwitchUserId) - && !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase)) - { - foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId)) - { - oldSession.IsActive = false; - } - } - - member.BoundTwitchUserId = twitchUserId; - member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName; - member.TwitchBoundAt = DateTimeOffset.UtcNow; - member.UpdatedAt = DateTimeOffset.UtcNow; - member.UpdatedByTwitchId = session.TwitchUserId; - - await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted)); - } - private static string BuildTeamSessionId(string login) => $"{TeamSessionPrefix}{login}"; @@ -215,7 +125,4 @@ public static partial class AuthEndpoints private static string NormalizeTwitchUserId(string? value) => (value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant(); - - private static string NormalizeTwitchDisplayName(string? value) => - (value ?? string.Empty).Trim(); } diff --git a/Backend/Endpoints/PublicVoteEndpoints.cs b/Backend/Endpoints/PublicVoteEndpoints.cs index 35f1d38..3648a20 100644 --- a/Backend/Endpoints/PublicVoteEndpoints.cs +++ b/Backend/Endpoints/PublicVoteEndpoints.cs @@ -4,6 +4,7 @@ using Backend.Data; using Backend.Domain; using Backend.Services; using Microsoft.EntityFrameworkCore; +using Npgsql; namespace Backend.Endpoints; @@ -72,29 +73,7 @@ public static partial class PublicEndpoints .FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId); var isResubmission = ballot is not null; - if (ballot is null) - { - ballot = new VoteBallot - { - SeasonId = request.SeasonId, - SubmittedByTwitchId = submitterId, - }; - - await db.VoteBallots.AddAsync(ballot); - } - else - { - db.VoteEntries.RemoveRange(ballot.Entries); - ballot.Entries.Clear(); - } - - ballot.SubmittedAt = DateTimeOffset.UtcNow; - ballot.Status = "submitted"; - ballot.Entries = request.Entries.Select(entry => new VoteEntry - { - CategoryId = entry.CategoryId, - CandidateId = entry.CandidateId, - }).ToList(); + ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted); var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted); var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted); @@ -102,7 +81,21 @@ public static partial class PublicEndpoints item.SubmittedByTwitchId == submitterId && item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes)); - await db.SaveChangesAsync(context.RequestAborted); + try + { + await db.SaveChangesAsync(context.RequestAborted); + } + catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error)) + { + db.ChangeTracker.Clear(); + ballot = await db.VoteBallots + .Include(item => item.Entries) + .FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted); + isResubmission = true; + ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted); + await db.SaveChangesAsync(context.RequestAborted); + } + var ballotLink = new { label = "Voting-Analytics öffnen", @@ -142,4 +135,42 @@ public static partial class PublicEndpoints await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission }); } + + private static async Task ApplyVoteEntriesAsync( + AwardsDbContext db, + VoteBallot? ballot, + int seasonId, + string submitterId, + VoteEntryRequest[] entries, + CancellationToken cancellationToken) + { + if (ballot is null) + { + ballot = new VoteBallot + { + SeasonId = seasonId, + SubmittedByTwitchId = submitterId, + }; + + await db.VoteBallots.AddAsync(ballot, cancellationToken); + } + else + { + db.VoteEntries.RemoveRange(ballot.Entries); + ballot.Entries.Clear(); + } + + ballot.SubmittedAt = DateTimeOffset.UtcNow; + ballot.Status = "submitted"; + ballot.Entries = entries.Select(entry => new VoteEntry + { + CategoryId = entry.CategoryId, + CandidateId = entry.CandidateId, + }).ToList(); + + return ballot; + } + + private static bool IsUniqueVoteBallotViolation(DbUpdateException error) => + error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; } diff --git a/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs index 5b85455..fee13bf 100644 --- a/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs +++ b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs @@ -9,10 +9,25 @@ public static partial class PublicEndpoints { private static async Task GetWinnerArchive(int year, AwardsDbContext db) { + var season = await db.Seasons + .AsNoTracking() + .Where(item => item.Year == year) + .Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase }) + .FirstOrDefaultAsync(); + if (season is null) + { + return Results.NotFound(); + } + + if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase)) + { + return Results.Ok(new WinnerArchiveResponse(year, [])); + } + var winnerRows = await db.Results .AsNoTracking() .Include(result => result.Candidate) - .Where(result => result.Season.Year == year) + .Where(result => result.SeasonId == season.Id) .OrderBy(result => result.CategoryName) .Select(result => new { @@ -34,4 +49,10 @@ public static partial class PublicEndpoints return Results.Ok(new WinnerArchiveResponse(year, items)); } + + private static bool CanExposeCurrentSeasonWinners(string currentPhase) + { + var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase); + return phaseKey is "completed"; + } } diff --git a/Backend/Migrations/20260626113057_AddVoteBallotSubmitterUniqueIndex.Designer.cs b/Backend/Migrations/20260626113057_AddVoteBallotSubmitterUniqueIndex.Designer.cs new file mode 100644 index 0000000..385541c --- /dev/null +++ b/Backend/Migrations/20260626113057_AddVoteBallotSubmitterUniqueIndex.Designer.cs @@ -0,0 +1,1471 @@ +// +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("20260626113057_AddVoteBallotSubmitterUniqueIndex")] + partial class AddVoteBallotSubmitterUniqueIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + CategoryId = 1, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + CategoryId = 1, + ChannelSlug = "@shiroch", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + CategoryId = 2, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + CategoryId = 2, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + CategoryId = 4, + ChannelSlug = "@moonrelay", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + CategoryId = 6, + ChannelSlug = "@kurainu", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + CategoryId = 8, + ChannelSlug = "@aoisakura", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + CategoryId = 9, + ChannelSlug = "@starbyte", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + CategoryId = 10, + ChannelSlug = "@tenshivox", + DisplayName = "Tenshi Vox", + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "" + }); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260626113057_AddVoteBallotSubmitterUniqueIndex.cs b/Backend/Migrations/20260626113057_AddVoteBallotSubmitterUniqueIndex.cs new file mode 100644 index 0000000..5af14c2 --- /dev/null +++ b/Backend/Migrations/20260626113057_AddVoteBallotSubmitterUniqueIndex.cs @@ -0,0 +1,135 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddVoteBallotSubmitterUniqueIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_VoteBallots_SeasonId", + table: "VoteBallots"); + + migrationBuilder.AddColumn( + name: "TwitchAuthManagedByDatabase", + table: "SiteSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TwitchClientId", + table: "SiteSettings", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TwitchClientSecret", + table: "SiteSettings", + type: "character varying(180)", + maxLength: 180, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TwitchRedirectUri", + table: "SiteSettings", + type: "character varying(400)", + maxLength: 400, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TwitchScope", + table: "SiteSettings", + type: "character varying(300)", + maxLength: 300, + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "TwitchAuthManagedByDatabase", "TwitchClientId", "TwitchClientSecret", "TwitchRedirectUri", "TwitchScope" }, + values: new object[] { false, "", "", "", "" }); + + migrationBuilder.Sql(""" + DELETE FROM "VoteEntries" + WHERE "BallotId" IN ( + SELECT "Id" + FROM ( + SELECT + "Id", + ROW_NUMBER() OVER ( + PARTITION BY "SeasonId", "SubmittedByTwitchId" + ORDER BY "SubmittedAt" DESC, "Id" DESC + ) AS duplicate_rank + FROM "VoteBallots" + ) ranked_ballots + WHERE duplicate_rank > 1 + ); + + DELETE FROM "VoteBallots" + WHERE "Id" IN ( + SELECT "Id" + FROM ( + SELECT + "Id", + ROW_NUMBER() OVER ( + PARTITION BY "SeasonId", "SubmittedByTwitchId" + ORDER BY "SubmittedAt" DESC, "Id" DESC + ) AS duplicate_rank + FROM "VoteBallots" + ) ranked_ballots + WHERE duplicate_rank > 1 + ); + """); + + migrationBuilder.CreateIndex( + name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId", + table: "VoteBallots", + columns: new[] { "SeasonId", "SubmittedByTwitchId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId", + table: "VoteBallots"); + + migrationBuilder.DropColumn( + name: "TwitchAuthManagedByDatabase", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "TwitchClientId", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "TwitchClientSecret", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "TwitchRedirectUri", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "TwitchScope", + table: "SiteSettings"); + + migrationBuilder.CreateIndex( + name: "IX_VoteBallots_SeasonId", + table: "VoteBallots", + column: "SeasonId"); + } + } +} diff --git a/Backend/Migrations/AwardsDbContextModelSnapshot.cs b/Backend/Migrations/AwardsDbContextModelSnapshot.cs index fc4f050..8a35478 100644 --- a/Backend/Migrations/AwardsDbContextModelSnapshot.cs +++ b/Backend/Migrations/AwardsDbContextModelSnapshot.cs @@ -966,6 +966,29 @@ namespace Backend.Migrations .HasMaxLength(400) .HasColumnType("character varying(400)"); + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + b.HasKey("Id"); b.ToTable("SiteSettings"); @@ -999,7 +1022,12 @@ namespace Backend.Migrations RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", - SponsorsUrl = "https://vtuber-star-awards.de/partner" + SponsorsUrl = "https://vtuber-star-awards.de/partner", + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "" }); }); @@ -1011,14 +1039,6 @@ namespace Backend.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedByTwitchId") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("character varying(120)"); - b.Property("BoundTwitchDisplayName") .HasMaxLength(120) .HasColumnType("character varying(120)"); @@ -1027,6 +1047,14 @@ namespace Backend.Migrations .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) @@ -1076,10 +1104,10 @@ namespace Backend.Migrations b.HasKey("Id"); - b.HasIndex("Login") + b.HasIndex("BoundTwitchUserId") .IsUnique(); - b.HasIndex("BoundTwitchUserId") + b.HasIndex("Login") .IsUnique(); b.ToTable("TeamMembers"); @@ -1197,7 +1225,8 @@ namespace Backend.Migrations b.HasKey("Id"); - b.HasIndex("SeasonId"); + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); b.ToTable("VoteBallots"); diff --git a/Backend/README.md b/Backend/README.md index f2885f1..0ec5161 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -105,6 +105,13 @@ VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin The frontend route is `/login`. `VTSA_DEMO_ADMIN_LOGIN` may be a username or an email-style identifier; the backend also accepts the configured email, Twitch ID, and display name for admin flexibility. Disable the demo login for release with `VTSA_DEMO_LOGIN_ENABLED=false`. +Team owner bootstrap credentials are separate from demo login credentials: + +```text +VTSA_TEAM_OWNER_PASSWORD= +VTSA_TEAM_CREATOR_PASSWORD= +``` + Frontend app-wide demo gate: ```text diff --git a/Backend/Security/AdminPermissionCatalog.cs b/Backend/Security/AdminPermissionCatalog.cs index 4c2479b..f770a2f 100644 --- a/Backend/Security/AdminPermissionCatalog.cs +++ b/Backend/Security/AdminPermissionCatalog.cs @@ -58,7 +58,7 @@ public static class AdminPermissionCatalog { AdminRoles.Owner => AllPermissionKeys, AdminRoles.Creator => AllPermissionKeys, - AdminRoles.Admin => AllPermissionKeys.Where(key => key is not Settings).ToArray(), + AdminRoles.Admin => AllPermissionKeys, AdminRoles.Member => [Dashboard, Nominations, Categories, Candidates, Clips, Content], AdminRoles.Reviewer => [Dashboard, Nominations, Clips, Risk, Audit], AdminRoles.OrganizationTeam => [Dashboard, Content, Analytics, Winners, Settings], @@ -67,6 +67,30 @@ public static class AdminPermissionCatalog }; } + public static string[] NormalizePermissionKeys(string? role, IEnumerable permissionKeys) + { + var normalizedRole = AdminRoles.Normalize(role); + var permissions = permissionKeys + .Where(IsKnownPermission) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (normalizedRole is AdminRoles.Owner or AdminRoles.Creator) + { + return AllPermissionKeys; + } + + if (DefaultPermissionKeys(normalizedRole).Contains(Settings, StringComparer.OrdinalIgnoreCase) + && !permissions.Contains(Settings, StringComparer.OrdinalIgnoreCase)) + { + permissions.Add(Settings); + } + + return permissions + .OrderBy(item => item) + .ToArray(); + } + public static async Task GetPermissionKeysAsync( AwardsDbContext db, string? role, @@ -92,11 +116,7 @@ public static class AdminPermissionCatalog try { - return (JsonSerializer.Deserialize(overrideJson) ?? fallback) - .Where(IsKnownPermission) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(item => item) - .ToArray(); + return NormalizePermissionKeys(normalizedRole, JsonSerializer.Deserialize(overrideJson) ?? fallback); } catch (JsonException) { diff --git a/Backend/Services/UserSessionService.cs b/Backend/Services/UserSessionService.cs index 28ea062..67a0740 100644 --- a/Backend/Services/UserSessionService.cs +++ b/Backend/Services/UserSessionService.cs @@ -9,6 +9,9 @@ namespace Backend.Services; public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService { + private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12); + private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30); + public async Task ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default) { var token = ReadBearerToken(context); @@ -23,7 +26,15 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit return null; } - session.LastSeenAt = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; + if (IsExpired(session, now)) + { + session.IsActive = false; + await userSessionRepository.SaveChangesAsync(cancellationToken); + return null; + } + + session.LastSeenAt = now; await userSessionRepository.SaveChangesAsync(cancellationToken); context.SetCurrentSession(session); return session; @@ -83,6 +94,10 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit : null; } + private static bool IsExpired(UserSession session, DateTimeOffset now) => + session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime) + || session.LastSeenAt <= now.Subtract(IdleSessionLifetime); + private async Task PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken) { await userSessionRepository.SaveChangesAsync(cancellationToken); diff --git a/frontend/index.html b/frontend/index.html index 4490a8d..433f901 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,9 @@ - + + + VTuber Star Awards diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..5341713 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-192.png b/frontend/public/favicon-192.png new file mode 100644 index 0000000..806d568 Binary files /dev/null and b/frontend/public/favicon-192.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..1394664 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon-512.png b/frontend/public/favicon-512.png new file mode 100644 index 0000000..0add949 Binary files /dev/null and b/frontend/public/favicon-512.png differ diff --git a/frontend/src/components/admin/AdminCandidateEditorModal.vue b/frontend/src/components/admin/AdminCandidateEditorModal.vue index ea8f10a..1ed5de9 100644 --- a/frontend/src/components/admin/AdminCandidateEditorModal.vue +++ b/frontend/src/components/admin/AdminCandidateEditorModal.vue @@ -16,12 +16,14 @@