Update release notes and deploy workspace
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 53s

This commit is contained in:
AzuTear
2026-06-29 17:49:54 +02:00
parent c466348d18
commit 441ef2b850
196 changed files with 9279 additions and 39292 deletions
+78 -27
View File
@@ -1,4 +1,5 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Security;
using Microsoft.EntityFrameworkCore;
@@ -20,37 +21,39 @@ public static class AdminDashboardEndpoints
return group;
}
private static async Task<IResult> GetDashboard(AwardsDbContext db, HttpContext context)
private static async Task<IResult> GetDashboard(int? seasonId, AwardsDbContext db, HttpContext context)
{
var canViewAuditIp = CanViewAuditIp(context);
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
if (currentSeason is null)
var selectedSeason = seasonId.HasValue
? await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.Id == seasonId.Value)
: await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
if (selectedSeason is null)
{
return Results.NotFound();
}
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id);
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id);
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.Status == "pending");
var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
var selectedSeasonId = selectedSeason.Id;
var phaseKey = SeasonMappings.NormalizePhaseKey(selectedSeason.CurrentPhase);
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId);
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == selectedSeasonId);
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == selectedSeasonId);
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId && item.Status == "pending");
var riskFlagCount = await db.RiskFlags.CountAsync(item =>
item.Status == "open" &&
(item.SeasonId == selectedSeasonId || item.SeasonId == null));
var globalRiskFlagCount = await db.RiskFlags.CountAsync(item =>
item.Status == "open" &&
item.SeasonId == null);
var topCategoryNames = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Ballot.SeasonId == currentSeason.Id)
.Select(item => item.Category.Name)
.ToListAsync();
var topCategories = topCategoryNames
.GroupBy(name => name)
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count()))
.OrderByDescending(item => item.Votes)
.Take(5)
.ToArray();
var topCategories = phaseKey == "nomination"
? await BuildTopNominationCategoriesAsync(db, selectedSeasonId)
: await BuildTopVotingCategoriesAsync(db, selectedSeasonId);
var riskFlags = await db.RiskFlags
.AsNoTracking()
.Where(item => item.Status == "open")
.Where(item =>
item.Status == "open" &&
(item.SeasonId == selectedSeasonId || item.SeasonId == null))
.OrderByDescending(item => item.CreatedAt)
.Take(8)
.ToArrayAsync();
@@ -74,18 +77,27 @@ public static class AdminDashboardEndpoints
.ToArrayAsync();
var activityItems = auditEntries
.Take(3)
.Take(6)
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
.ToArray();
return Results.Ok(new AdminDashboardResponse(
selectedSeason.Id,
selectedSeason.Year,
selectedSeason.Name,
selectedSeason.IsCurrent,
new[]
{
new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"),
new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf"),
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
new AdminMetricDto("Nominierungen", nominationCount, $"Gespeicherte Einreichungen im Award-Jahr {selectedSeason.Year}"),
new AdminMetricDto("Stimmen", voteCount, $"Abgegebene Stimmen im Award-Jahr {selectedSeason.Year}"),
new AdminMetricDto("Kategorien", categoryCount, $"Aktive Kategorien im Award-Jahr {selectedSeason.Year}"),
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf in diesem Jahr"),
new AdminMetricDto(
"Risikohinweise",
riskFlagCount,
globalRiskFlagCount > 0
? $"Offene Hinweise fuer {selectedSeason.Year}, inklusive {globalRiskFlagCount} globaler Hinweise"
: $"Offene Hinweise fuer {selectedSeason.Year}"),
},
activityItems,
topCategories,
@@ -93,6 +105,45 @@ public static class AdminDashboardEndpoints
auditEntries));
}
private static async Task<AdminTopCategoryDto[]> BuildTopVotingCategoriesAsync(AwardsDbContext db, int seasonId)
{
var categoryNames = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Ballot.SeasonId == seasonId)
.Select(item => item.Category.Name)
.ToListAsync();
return categoryNames
.GroupBy(name => name)
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Stimmen"))
.OrderByDescending(item => item.Value)
.Take(5)
.ToArray();
}
private static async Task<AdminTopCategoryDto[]> BuildTopNominationCategoriesAsync(AwardsDbContext db, int seasonId)
{
var nominationCategories = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => new
{
CategoryName = item.Category != null ? item.Category.Name : null,
item.CategoryGroupName,
})
.ToListAsync();
return nominationCategories
.Select(item => string.IsNullOrWhiteSpace(item.CategoryName)
? string.IsNullOrWhiteSpace(item.CategoryGroupName) ? "Ohne Kategorie" : item.CategoryGroupName
: item.CategoryName)
.GroupBy(name => name)
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Nominierungen"))
.OrderByDescending(item => item.Value)
.Take(5)
.ToArray();
}
private static async Task<IResult> GetAuditEntries(
int? limit,
string? query,
@@ -27,7 +27,6 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
}
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
@@ -37,7 +36,7 @@ public static partial class AdminSeasonManagementEndpoints
{
Year = request.Year,
Name = request.Name.Trim(),
ShowStreamUrl = showStreamUrl,
IsDemo = false,
CurrentPhase = request.CurrentPhase.Trim(),
IsCurrent = request.IsCurrent,
IsCommunityOnly = request.IsCommunityOnly,
@@ -125,7 +124,6 @@ public static partial class AdminSeasonManagementEndpoints
{
request.IsCurrent,
request.IsCommunityOnly,
showStreamUrl,
request.CurrentPhase,
request.ShowDate,
request.ShowStartsAt,
+185 -1
View File
@@ -208,6 +208,23 @@ public static partial class AdminSeasonManagementEndpoints
item.Candidate.Platform))
.ToArrayAsync();
var workflowRules = WorkflowRuleSettings.Read(season, settings);
var votingEntryRows = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Category.SeasonId == seasonId)
.Select(item => new AdminVotingEntryRow(
item.BallotId,
item.CategoryId,
item.CandidateId))
.ToArrayAsync();
var votingWorkspace = BuildVotingWorkspace(
categories,
candidates,
pendingNominations,
resultItems,
votingEntryRows,
workflowRules);
var clipSubmissions = await db.ClipSubmissions
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
@@ -232,7 +249,7 @@ public static partial class AdminSeasonManagementEndpoints
season.Id,
season.Year,
season.Name,
NormalizeSeasonStreamUrl(season.ShowStreamUrl),
season.IsDemo,
season.CurrentPhase,
season.IsCurrent,
season.IsCommunityOnly,
@@ -244,6 +261,8 @@ public static partial class AdminSeasonManagementEndpoints
season.ReviewEndsAt,
season.ShowDate,
season.ShowStartsAt,
season.WinnersPublishedAt,
season.WinnersPublishedByTwitchId,
subcategoryTemplates,
categories,
candidates,
@@ -253,9 +272,15 @@ public static partial class AdminSeasonManagementEndpoints
settings?.TrackingReviewNotes ?? string.Empty,
trackingRules.Source.ShowManualReviewNotesInReview,
resultItems,
votingWorkspace,
clipSubmissions));
}
private sealed record AdminVotingEntryRow(
int BallotId,
int CategoryId,
int CandidateId);
private sealed record AdminNominationRow(
int Id,
int? CategoryId,
@@ -330,6 +355,165 @@ public static partial class AdminSeasonManagementEndpoints
item.ReviewedAt);
}
private static AdminVotingWorkspaceDto BuildVotingWorkspace(
AdminCategoryItemDto[] categories,
AdminCandidateItemDto[] candidates,
AdminNominationReviewItemDto[] pendingNominations,
AdminAwardResultItemDto[] results,
AdminVotingEntryRow[] voteEntries,
WorkflowRuleSetting[] workflowRules)
{
var resultMap = results.ToDictionary(item => item.CategoryId);
var totalBallots = voteEntries.Select(item => item.BallotId).Distinct().Count();
var voteCountByCategory = voteEntries
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => group.Count());
var ballotCountByCategory = voteEntries
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => group.Select(item => item.BallotId).Distinct().Count());
var voteCountByCandidate = voteEntries
.GroupBy(item => (item.CategoryId, item.CandidateId))
.ToDictionary(group => group.Key, group => group.Count());
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
var recommendedNominatorsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.RecommendedNominatorsPerSubcategory);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
var workspaceItems = categories
.Select(category =>
{
var categoryCandidates = candidates
.Where(candidate => candidate.CategoryId == category.Id)
.ToArray();
var categoryVoteCount = voteCountByCategory.TryGetValue(category.Id, out var voteCount) ? voteCount : 0;
var categoryBallotCount = ballotCountByCategory.TryGetValue(category.Id, out var ballotCount) ? ballotCount : 0;
var nominationCount = categoryCandidates.Sum(candidate => Math.Max(candidate.NominationTally, 0));
var openReviewCount = pendingNominations.Count(item =>
item.CategoryId == category.Id
|| item.SuggestedCategoryId == category.Id);
var existingResult = resultMap.GetValueOrDefault(category.Id);
var maxVotes = categoryCandidates.Length == 0
? 0
: categoryCandidates.Max(candidate => voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0));
var topVoteTieCount = maxVotes <= 0
? 0
: categoryCandidates.Count(candidate => voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0) == maxVotes);
var leaderboard = categoryCandidates
.Select(candidate =>
{
var candidateVotes = voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0);
var hasWinnerConflict = CandidateHasWinnerConflict(candidate, category.Id, results, winnerPlacementsRule);
return new AdminVotingCandidateRankDto(
candidate.Id,
candidate.DisplayName,
candidate.ChannelSlug,
candidate.Platform,
candidateVotes,
categoryVoteCount > 0 ? (int)Math.Round(candidateVotes * 100d / categoryVoteCount) : 0,
Math.Max(candidate.NominationTally, 0),
!string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl),
string.IsNullOrWhiteSpace(candidate.ClipEmbedStatus) ? "unchecked" : candidate.ClipEmbedStatus,
hasWinnerConflict,
existingResult?.CandidateId == candidate.Id,
!string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase),
maxVotes > 0 && candidateVotes == maxVotes && topVoteTieCount > 1);
})
.OrderByDescending(item => item.Votes)
.ThenByDescending(item => item.NominationTally)
.ThenBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var readyCandidates = leaderboard.Count(item => item.IsAccepted);
var leadingCandidate = leaderboard.FirstOrDefault();
var hasMissingClip = winnerRequiresClipRule.Enabled
&& leadingCandidate is not null
&& !leadingCandidate.HasClip;
var hasRuleConflict = leadingCandidate?.HasWinnerConflict ?? false;
var hasOpenReviews = openReviewCount > 0;
var hasSoftNominatorWarning = recommendedNominatorsRule.Enabled
&& nominationCount < Math.Max(1, recommendedNominatorsRule.Limit);
var winnerReady = existingResult is not null
|| leadingCandidate is not null
&& leadingCandidate.IsAccepted
&& !hasMissingClip
&& !hasRuleConflict
&& !hasOpenReviews;
return new AdminVotingCategoryWorkspaceItemDto(
category.Id,
category.GroupName,
category.Name,
category.SortOrder,
category.ViewerRangeMin,
category.ViewerRangeMax,
categoryVoteCount,
categoryBallotCount,
categoryCandidates.Length,
readyCandidates,
nominationCount,
openReviewCount,
existingResult is not null,
winnerReady,
topVoteTieCount > 1,
hasMissingClip,
hasRuleConflict,
hasOpenReviews,
hasSoftNominatorWarning,
leaderboard);
})
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.CategoryName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var summary = new AdminVotingWorkspaceSummaryDto(
voteEntries.Length,
totalBallots,
workspaceItems.Length,
workspaceItems.Count(item => item.VoteCount > 0),
workspaceItems.Count(item => item.WinnerReady),
workspaceItems.Count(item =>
item.HasMissingClip
|| item.HasRuleConflict
|| item.HasOpenReviews
|| item.HasTopVoteTie),
workspaceItems.Count(item => item.HasWinner));
return new AdminVotingWorkspaceDto(summary, workspaceItems);
}
private static bool CandidateHasWinnerConflict(
AdminCandidateItemDto candidate,
int categoryId,
AdminAwardResultItemDto[] results,
WorkflowRuleSetting winnerPlacementsRule)
{
if (!winnerPlacementsRule.Enabled)
{
return false;
}
var identityKey = candidate.StreamerIdentityId.HasValue
? $"identity:{candidate.StreamerIdentityId.Value}"
: WorkflowRuleSettings.CandidateIdentityKey(candidate.DisplayName, candidate.ChannelSlug);
var existingWinnerCount = results.Count(result =>
{
if (result.CategoryId == categoryId)
{
return false;
}
var resultIdentityKey = result.StreamerIdentityId.HasValue
? $"identity:{result.StreamerIdentityId.Value}"
: WorkflowRuleSettings.CandidateIdentityKey(result.CandidateDisplayName, result.CandidateChannelSlug);
return string.Equals(resultIdentityKey, identityKey, StringComparison.Ordinal);
});
return existingWinnerCount >= winnerPlacementsRule.Limit;
}
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
IEnumerable<AdminNominationRow> rows,
IEnumerable<dynamic> categoryRows,
@@ -17,7 +17,10 @@ public static partial class AdminSeasonManagementEndpoints
item.Name,
item.CurrentPhase,
item.IsCurrent,
item.Categories.Count))
item.IsDemo,
item.Categories.Count,
item.WinnersPublishedAt,
item.WinnersPublishedByTwitchId))
.ToArrayAsync();
return Results.Ok(seasons);
@@ -72,6 +72,14 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("DeleteAdminResult")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/winners/publish", PublishWinners)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("PublishAdminSeasonWinners")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/winners/unpublish", UnpublishWinners)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("UnpublishAdminSeasonWinners")
.WithOpenApi();
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("GetAdminWorkflowRules")
@@ -44,6 +44,13 @@ public static partial class AdminSeasonManagementEndpoints
string ChannelSlug,
string? ClipCompilationUrl);
private sealed record WinnerPublicationSnapshot(
int CategoryId,
int? StreamerIdentityId,
string DisplayName,
string ChannelSlug,
string? ClipCompilationUrl);
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
{
if (request.Year < 2020 || request.Year > 2100)
@@ -108,11 +115,6 @@ public static partial class AdminSeasonManagementEndpoints
return null;
}
private static string NormalizeSeasonStreamUrl(string? showStreamUrl)
{
return SeasonMappings.NormalizeSeasonStreamUrl(showStreamUrl);
}
private static bool IsKnownSeasonPhase(string? currentPhase)
{
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
@@ -156,7 +158,17 @@ public static partial class AdminSeasonManagementEndpoints
var issueList = issues.ToArray();
return Results.BadRequest(new
{
message = $"Public-/Archiv-Readiness blockiert: {string.Join(" ", issueList)}",
message = $"Landingpage-Freigabe blockiert: {string.Join(" ", issueList)}",
issues = issueList,
});
}
private static IResult CreateWinnerPublicationError(IEnumerable<string> issues)
{
var issueList = issues.ToArray();
return Results.BadRequest(new
{
message = $"Gewinner-Freigabe blockiert: {string.Join(" ", issueList)}",
issues = issueList,
});
}
@@ -421,6 +433,87 @@ public static partial class AdminSeasonManagementEndpoints
return issues.ToArray();
}
private static async Task<string[]> BuildWinnerPublicationIssuesAsync(
AwardsDbContext db,
int seasonId,
CancellationToken cancellationToken)
{
var categoryIds = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => item.Id)
.ToArrayAsync(cancellationToken);
var issues = new List<string>();
if (categoryIds.Length == 0)
{
issues.Add("Mindestens eine Kategorie ist erforderlich.");
return issues.ToArray();
}
var resultSnapshots = await db.Results
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => new WinnerPublicationSnapshot(
item.CategoryId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
item.Candidate.ClipCompilationUrl))
.ToArrayAsync(cancellationToken);
var categoriesWithResults = resultSnapshots
.Select(item => item.CategoryId)
.Distinct()
.Count();
var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults);
if (missingResults > 0)
{
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
}
var openReviewCount = await db.Nominations
.AsNoTracking()
.CountAsync(item => item.SeasonId == seasonId && item.Status == "pending", cancellationToken);
if (openReviewCount > 0)
{
issues.Add($"{openReviewCount} Nominierungs-Review(s) sind noch offen.");
}
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
{
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
if (missingWinnerClipCount > 0)
{
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
}
}
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
{
var winnerOverflow = resultSnapshots
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
.Select(group => new
{
Count = group.Count(),
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
})
.Where(item => item.Count > winnerPlacementsRule.Limit)
.OrderByDescending(item => item.Count)
.FirstOrDefault();
if (winnerOverflow is not null)
{
issues.Add(
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
}
}
return issues.ToArray();
}
private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug)
{
if (streamerIdentityId.HasValue)
@@ -73,6 +73,7 @@ public static partial class AdminSeasonManagementEndpoints
}
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId);
@@ -94,6 +95,13 @@ public static partial class AdminSeasonManagementEndpoints
existingResult.CategoryName = category.Name;
}
var wasPublished = season?.WinnersPublishedAt is not null;
if (wasPublished && season is not null)
{
season.WinnersPublishedAt = null;
season.WinnersPublishedByTwitchId = null;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"result.set",
@@ -106,6 +114,7 @@ public static partial class AdminSeasonManagementEndpoints
categoryId = request.CategoryId,
candidateId = request.CandidateId,
candidateName = candidate.DisplayName,
unpublishedWinners = wasPublished,
},
RequestMetadataReader.Read(context));
@@ -130,12 +139,20 @@ public static partial class AdminSeasonManagementEndpoints
var result = await db.Results
.Include(item => item.Category)
.Include(item => item.Candidate)
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == resultId);
if (result is null)
{
return Results.NotFound();
}
var wasPublished = result.Season.WinnersPublishedAt is not null;
if (wasPublished)
{
result.Season.WinnersPublishedAt = null;
result.Season.WinnersPublishedByTwitchId = null;
}
db.Results.Remove(result);
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -149,10 +166,98 @@ public static partial class AdminSeasonManagementEndpoints
result.CategoryId,
result.CandidateId,
candidateName = result.Candidate.DisplayName,
unpublishedWinners = wasPublished,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, resultId });
}
private static async Task<IResult> PublishWinners(
HttpContext context,
int seasonId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
if (season is null)
{
return Results.NotFound();
}
var issues = await BuildWinnerPublicationIssuesAsync(db, seasonId, context.RequestAborted);
if (issues.Length > 0)
{
return CreateWinnerPublicationError(issues);
}
season.WinnersPublishedAt = DateTimeOffset.UtcNow;
season.WinnersPublishedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"winners.publish",
"season",
season.Id.ToString(),
$"Gewinner für {season.Year} wurden veröffentlicht.",
new
{
seasonId,
season.Year,
season.WinnersPublishedAt,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new
{
saved = true,
seasonId,
winnersPublishedAt = season.WinnersPublishedAt,
winnersPublishedByTwitchId = season.WinnersPublishedByTwitchId,
});
}
private static async Task<IResult> UnpublishWinners(
HttpContext context,
int seasonId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
if (season is null)
{
return Results.NotFound();
}
var previousPublishedAt = season.WinnersPublishedAt;
season.WinnersPublishedAt = null;
season.WinnersPublishedByTwitchId = null;
adminAuditService.AddEntry(
session.TwitchUserId,
"winners.unpublish",
"season",
season.Id.ToString(),
$"Gewinner für {season.Year} wurden von der Landingpage zurückgenommen.",
new
{
seasonId,
season.Year,
previousPublishedAt,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new
{
saved = true,
seasonId,
winnersPublishedAt = season.WinnersPublishedAt,
winnersPublishedByTwitchId = season.WinnersPublishedByTwitchId,
});
}
}
@@ -33,8 +33,6 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
}
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
var wasCurrent = season.IsCurrent;
var previousPhase = season.CurrentPhase;
var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase);
@@ -58,7 +56,6 @@ public static partial class AdminSeasonManagementEndpoints
season.Year = request.Year;
season.Name = request.Name.Trim();
season.ShowStreamUrl = showStreamUrl;
season.CurrentPhase = request.CurrentPhase.Trim();
season.IsCommunityOnly = request.IsCommunityOnly;
season.NominationStartsAt = request.NominationStartsAt;
@@ -98,7 +95,6 @@ public static partial class AdminSeasonManagementEndpoints
{
request.Year,
request.Name,
showStreamUrl,
previousPhase,
request.CurrentPhase,
wasCurrent,
@@ -86,6 +86,22 @@ public static class AdminSiteSettingsEndpoints
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 ?? "[]"));
@@ -134,6 +150,22 @@ public static class AdminSiteSettingsEndpoints
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
settings.ShowactsContent = request.ShowactsContent.Trim();
settings.StreamBannerEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerEyebrow);
settings.StreamBannerTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerTitle);
settings.StreamBannerText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerText);
settings.StreamBannerLiveButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLiveButtonLabel);
settings.StreamBannerLiveButtonUrl = normalizedUrls.StreamBannerLiveButtonUrl;
settings.StreamBannerLockedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLockedButtonLabel);
settings.StreamBannerUseCompletedContent = request.StreamBannerUseCompletedContent;
settings.StreamBannerCompletedEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedEyebrow);
settings.StreamBannerCompletedTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedTitle);
settings.StreamBannerCompletedText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedText);
settings.StreamBannerCompletedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedButtonLabel);
settings.StreamBannerCompletedButtonUrl = normalizedUrls.StreamBannerCompletedButtonUrl;
settings.AwardsSectionTitle = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionTitle);
settings.AwardsSectionDescription = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionDescription);
settings.SubcategoriesSectionTitle = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionTitle);
settings.SubcategoriesSectionDescription = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionDescription);
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
@@ -171,7 +203,9 @@ public static class AdminSiteSettingsEndpoints
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage))
|| !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.StreamBannerLiveButtonUrl, "Finale-Banner Button-Link", out var streamBannerLiveButtonUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.StreamBannerCompletedButtonUrl, "Finale-Banner Abschluss-Link", out var streamBannerCompletedButtonUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
@@ -185,6 +219,8 @@ public static class AdminSiteSettingsEndpoints
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
ShowactsUrl = showactsUrl,
StreamBannerLiveButtonUrl = streamBannerLiveButtonUrl,
StreamBannerCompletedButtonUrl = streamBannerCompletedButtonUrl,
};
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
@@ -231,6 +267,8 @@ public static class AdminSiteSettingsEndpoints
public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
public string ShowactsUrl { get; set; } = string.Empty;
public string StreamBannerLiveButtonUrl { get; set; } = string.Empty;
public string StreamBannerCompletedButtonUrl { get; set; } = string.Empty;
}
private static async Task<IResult> GetOptionalFeatureSettings(AwardsDbContext db)
+14 -13
View File
@@ -19,19 +19,20 @@ public static class AdminTeamEndpoints
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
[
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "/admin/dashboard", true),
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "/admin/years", false),
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "/admin/nominations", false),
new(AdminPermissionCatalog.Categories, "Kategorien", "Kategorien und Limits verwalten.", "/admin/categories", false),
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis bearbeiten.", "/admin/candidates", false),
new(AdminPermissionCatalog.Clips, "Clips", "Clip-Einreichungen prüfen.", "/admin/clips", false),
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "/admin/risk", true),
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "/admin/users-logs", true),
new(AdminPermissionCatalog.Analytics, "Analytics", "Metriken und Rankings lesen.", "/admin/analytics", true),
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "/admin/winners", false),
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Datenschutz und öffentliche Inhalte pflegen.", "/admin/content", false),
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang und Wartung sehen.", "/admin/settings", true),
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "/admin/team", false),
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "Betrieb", "/admin/dashboard", true),
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "Betrieb", "/admin/nominations", false),
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "Awards", "/admin/years", false),
new(AdminPermissionCatalog.Categories, "Kategorien", "Hauptkategorien, Unterkategorien und Limits verwalten.", "Awards", "/admin/categories", false),
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis, Clips und Annahmestatus pflegen.", "Awards", "/admin/candidates", false),
new(AdminPermissionCatalog.Clips, "Clips", "Optionale Clip-Einreichungen prüfen.", "Awards", "/admin/clips", false),
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Footer, Showacts und öffentliche Inhalte pflegen.", "Landingpage", "/admin/content", false),
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "Kontrolle", "/admin/risk", true),
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "Kontrolle", "/admin/users-logs", true),
new(AdminPermissionCatalog.Analytics, "Analytics", "Jahresmetriken und Überblick lesen.", "Auswertung", "/admin/analytics", true),
new(AdminPermissionCatalog.Voting, "Voting", "Stimmenlage und Gewinner-Vorbereitung sehen.", "Auswertung", "/admin/voting", true),
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "Auswertung", "/admin/winners", false),
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang, Wartung und Workflow-Steuerung sehen.", "Einstellungen", "/admin/settings", true),
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "Einstellungen", "/admin/team", false),
];
private static readonly AdminTeamRoleDto[] DefaultRoles =
+35 -9
View File
@@ -29,21 +29,26 @@ public static partial class PublicEndpoints
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
var canExposeCurrentSeasonWinners = CanExposeCurrentSeasonWinners(season.CurrentPhase);
var latestPublishedWinnerYear = await db.Results
.AsNoTracking()
.Where(result => result.Season.WinnersPublishedAt != null)
.Select(result => (int?)result.Season.Year)
.MaxAsync();
var winnerPreviewRows = await db.Results
.AsNoTracking()
.Include(result => result.Season)
.Include(result => result.Candidate)
.Where(result =>
result.Season.Year < season.Year
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
.Where(result => result.Season.WinnersPublishedAt != null
&& latestPublishedWinnerYear != null
&& result.Season.Year == latestPublishedWinnerYear.Value)
.OrderByDescending(result => result.Season.Year)
.ThenBy(result => result.CategoryName)
.Take(8)
.Select(result => new
{
Year = result.Season.Year,
CategoryGroup = result.Category.GroupName,
result.CategoryName,
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
@@ -58,6 +63,7 @@ public static partial class PublicEndpoints
var winnerPreviewItems = winnerPreviewRows
.Select(result => new WinnerPreviewDto(
result.Year,
result.CategoryGroup,
result.CategoryName,
result.WinnerName,
result.WinnerSlug,
@@ -71,9 +77,9 @@ public static partial class PublicEndpoints
var archiveYearRows = await db.Results
.AsNoTracking()
.Where(result =>
result.Season.Year < season.Year
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
.Where(result => result.Season.WinnersPublishedAt != null
&& latestPublishedWinnerYear != null
&& result.Season.Year < latestPublishedWinnerYear.Value)
.GroupBy(result => result.Season.Year)
.Select(group => new
{
@@ -100,6 +106,10 @@ public static partial class PublicEndpoints
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
var first = ordered[0];
var groupDescription = ordered
.Select(category => category.Description?.Trim())
.FirstOrDefault(description => !string.IsNullOrWhiteSpace(description))
?? string.Empty;
var maxNomineesPerUser = ordered
.Select(category => category.MaxNomineesPerUser)
.Where(value => value > 0)
@@ -110,7 +120,7 @@ public static partial class PublicEndpoints
first.Id,
first.GroupName,
first.GroupName,
first.Description,
groupDescription,
maxNomineesPerUser);
})
.OrderBy(category => publicCategories
@@ -124,7 +134,6 @@ public static partial class PublicEndpoints
season.Name,
season.ShowDate,
season.ShowStartsAt,
SeasonMappings.NormalizeSeasonStreamUrl(season.ShowStreamUrl),
season.CurrentPhase,
season.IsCommunityOnly,
"Twitch",
@@ -146,6 +155,23 @@ public static partial class PublicEndpoints
siteSettings.ShareDiscordUrl,
siteSettings.PrivacyEmail,
siteSettings.PrivacyPolicyContent,
SeasonMappings.NormalizePlainTextContent(siteSettings.AwardsSectionTitle),
SeasonMappings.NormalizePlainTextContent(siteSettings.AwardsSectionDescription),
SeasonMappings.NormalizePlainTextContent(siteSettings.SubcategoriesSectionTitle),
SeasonMappings.NormalizePlainTextContent(siteSettings.SubcategoriesSectionDescription),
new PublicStreamBannerContentDto(
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerEyebrow),
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerTitle),
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerText),
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerLiveButtonLabel),
SeasonMappings.NormalizeSeasonStreamUrl(siteSettings.StreamBannerLiveButtonUrl),
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerLockedButtonLabel),
siteSettings.StreamBannerUseCompletedContent,
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedEyebrow),
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedTitle),
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedText),
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedButtonLabel),
SeasonMappings.NormalizeSeasonStreamUrl(siteSettings.StreamBannerCompletedButtonUrl)),
SeasonMappings.ReadSocialLinks(siteSettings),
SeasonMappings.BuildFooterLinks(siteSettings)),
new PublicFeatureFlagsDto(
@@ -57,8 +57,6 @@ public static partial class PublicEndpoints
category.GroupName,
category.Description,
category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
category.Candidates
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
.Select(candidate =>
@@ -12,14 +12,24 @@ public static partial class PublicEndpoints
var season = await db.Seasons
.AsNoTracking()
.Where(item => item.Year == year)
.Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase })
.Select(item => new { item.Id, item.Year, item.WinnersPublishedAt })
.FirstOrDefaultAsync();
if (season is null)
{
return Results.NotFound();
}
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
if (season.WinnersPublishedAt is null)
{
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, []));
}
@@ -31,6 +41,7 @@ public static partial class PublicEndpoints
.OrderBy(result => result.CategoryName)
.Select(result => new
{
CategoryGroup = result.Category.GroupName,
result.CategoryName,
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
@@ -44,6 +55,7 @@ public static partial class PublicEndpoints
var items = winnerRows
.Select(result => new WinnerArchiveItemDto(
result.CategoryGroup,
result.CategoryName,
result.WinnerName,
result.WinnerSlug,
@@ -57,10 +69,4 @@ 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";
}
}