Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season

Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-28 23:32:21 +02:00
parent 4b2c5fa15d
commit b53c7fb736
178 changed files with 28933 additions and 1908 deletions
+2 -1
View File
@@ -303,7 +303,8 @@ public static class AdminExtrasEndpoints
application.Status,
application.ReviewNote,
application.CreatedAt,
application.ReviewedAt);
application.ReviewedAt,
application.FieldResponsesJson ?? "{}");
private static string NormalizeStatus(string? value) =>
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
@@ -20,6 +20,14 @@ public static partial class AdminModerationEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("RejectAdminNomination")
.WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("ReopenRejectedAdminNomination")
.WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("UpdateNominationTrackingReview")
.WithOpenApi();
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("GetAdminNominationLinkBlacklist")
@@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations
.Include(item => item.Category)
.Include(item => item.SuggestedCategory)
.Include(item => item.StreamerIdentity)
.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
@@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints
return Results.NotFound();
}
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
if (trackingFlags.Any(flag => flag.BlocksApproval)
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
}
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
if (string.IsNullOrWhiteSpace(rawDisplayName))
{
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
}
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
if (!categoryId.HasValue)
{
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
}
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
item.Id == categoryId.Value
&& item.SeasonId == nomination.SeasonId
&& item.GroupName == nomination.CategoryGroupName);
if (targetCategory is null)
{
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
}
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
var platform = string.IsNullOrWhiteSpace(request.Platform)
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
: request.Platform.Trim();
var normalizedDisplayName = rawDisplayName.ToLower();
var normalizedChannelSlug = channelSlug.ToLower();
var normalizedPlatform = platform.ToLower();
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryId == nomination.CategoryId
&& item.CategoryId == targetCategory.Id
&& (
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
||
item.DisplayName.ToLower() == normalizedDisplayName
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
&& item.Platform.ToLower() == normalizedPlatform)
));
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
db,
nomination.SeasonId,
targetCategory.Id,
existingCandidate?.Id,
nomination.StreamerIdentityId,
rawDisplayName,
channelSlug,
existingCandidate?.AcceptanceStatus ?? "open",
context.RequestAborted);
if (workflowRuleBlock is not null)
{
return workflowRuleBlock;
}
var candidate = existingCandidate;
if (candidate is null)
{
candidate = new Candidate
{
SeasonId = nomination.SeasonId,
CategoryId = nomination.CategoryId,
CategoryId = targetCategory.Id,
StreamerIdentityId = nomination.StreamerIdentityId,
DisplayName = rawDisplayName,
ChannelSlug = channelSlug,
Platform = platform,
@@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints
}
else
{
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
candidate.DisplayName = rawDisplayName;
if (!string.IsNullOrWhiteSpace(channelSlug))
@@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints
}
}
nomination.CandidateId = candidate.Id;
nomination.Status = "approved";
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
nomination.ReviewedAt = DateTimeOffset.UtcNow;
nomination.ReviewedByTwitchId = session.TwitchUserId;
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
var uniqueViewerCount = relatedNominations
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.Count();
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
foreach (var relatedNomination in relatedNominations)
{
relatedNomination.CandidateId = candidate.Id;
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
relatedNomination.Status = "approved";
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
}
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.approve",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
new
{
candidateId = candidate.Id,
created = existingCandidate is null,
targetCategoryId = targetCategory.Id,
targetCategoryName = targetCategory.Name,
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
uniqueViewerCount,
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
return Results.Ok(new
{
saved = true,
nominationId = nomination.Id,
candidateId = candidate.Id,
created = existingCandidate is null,
uniqueViewerCount,
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
});
}
private static async Task<IResult> RejectNomination(
@@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints
return Results.NotFound();
}
nomination.CandidateId = null;
nomination.Status = "rejected";
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
nomination.ReviewedAt = DateTimeOffset.UtcNow;
nomination.ReviewedByTwitchId = session.TwitchUserId;
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
foreach (var relatedNomination in relatedNominations)
{
relatedNomination.CandidateId = null;
relatedNomination.Status = "rejected";
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
}
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde verworfen.",
new { nomination.ReviewNote },
new
{
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
}
private static async Task<IResult> ReopenRejectedNomination(
HttpContext context,
int nominationId,
ReopenRejectedNominationRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
if (nomination is null)
{
return Results.NotFound();
}
if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." });
}
var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted);
foreach (var relatedNomination in relatedNominations)
{
relatedNomination.CandidateId = null;
relatedNomination.Status = "pending";
relatedNomination.ReviewNote = null;
relatedNomination.ReviewedAt = null;
relatedNomination.ReviewedByTwitchId = null;
}
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.reopen",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.",
new
{
reviewNote,
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
}
private static async Task<IResult> UpdateNominationTrackingReview(
HttpContext context,
int nominationId,
UpdateNominationTrackingReviewRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
if (nomination is null)
{
return Results.NotFound();
}
var normalizedStatus = request.Status?.Trim().ToLowerInvariant();
if (normalizedStatus is not ("reviewed" or "overridden"))
{
return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." });
}
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
var requiresOverrideNote = relatedNominations
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
.Any(flag => flag.AdminNoteRequiredOnOverride);
if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote))
{
return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." });
}
foreach (var item in relatedNominations)
{
item.TrackingReviewStatus = normalizedStatus;
item.TrackingReviewNote = reviewNote;
item.TrackingReviewedByTwitchId = session.TwitchUserId;
item.TrackingReviewedAt = DateTimeOffset.UtcNow;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.tracking-review.update",
"nomination",
nomination.Id.ToString(),
$"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.",
new
{
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
status = normalizedStatus,
reviewNote,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId, status = normalizedStatus });
}
private static async Task<Nomination[]> FindRelatedPendingNominationsAsync(
AwardsDbContext db,
Nomination nomination,
CancellationToken cancellationToken)
=> await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken);
private static async Task<Nomination[]> FindRelatedNominationsByStatusAsync(
AwardsDbContext db,
Nomination nomination,
string status,
CancellationToken cancellationToken)
{
var query = db.Nominations
.Where(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryGroupName == nomination.CategoryGroupName
&& item.Status == status);
if (nomination.StreamerIdentityId.HasValue)
{
return await query
.Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId)
.ToArrayAsync(cancellationToken);
}
var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl);
if (!string.IsNullOrWhiteSpace(normalizedStreamUrl))
{
var rows = await query.ToArrayAsync(cancellationToken);
return rows
.Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
return [nomination];
}
private static string NormalizeModerationStreamUrl(string? value) =>
(value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
private static string FirstNonEmpty(params string?[] values) =>
values
.Select(value => value?.Trim() ?? string.Empty)
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
?? string.Empty;
private static async Task<IResult?> BuildModerationCandidateWorkflowRuleBlockAsync(
AwardsDbContext db,
int seasonId,
int categoryId,
int? existingCandidateId,
int? streamerIdentityId,
string displayName,
string channelSlug,
string acceptanceStatus,
CancellationToken cancellationToken)
{
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
var rules = WorkflowRuleSettings.Read(season, settings);
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
{
return null;
}
var existingCandidates = await db.Candidates
.AsNoTracking()
.Where(item =>
item.SeasonId == seasonId
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
&& item.AcceptanceStatus != "declined")
.Select(item => new
{
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
})
.ToArrayAsync(cancellationToken);
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
{
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
if (categoryCount >= finalistsRule.Limit)
{
return CreateModerationWorkflowRuleError(
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
}
}
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
{
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
var appearanceCount = existingCandidates.Count(item =>
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
if (appearanceCount >= appearancesRule.Limit)
{
return CreateModerationWorkflowRuleError(
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
}
}
return null;
}
private static IResult CreateModerationWorkflowRuleError(string message) =>
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId)
{
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
if (trackingFlags.Length == 0)
{
nomination.TrackingReviewStatus = "clear";
nomination.TrackingReviewNote = null;
nomination.TrackingReviewedByTwitchId = null;
nomination.TrackingReviewedAt = null;
return;
}
var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim();
nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note))
? "overridden"
: "reviewed";
nomination.TrackingReviewNote = note;
nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId;
nomination.TrackingReviewedAt = DateTimeOffset.UtcNow;
}
}
@@ -62,6 +62,7 @@ public static partial class AdminSeasonManagementEndpoints
seasonId,
request.CategoryId,
null,
null,
normalizedDisplayName,
normalizedChannelSlug,
normalizedAcceptanceStatus,
@@ -158,6 +159,7 @@ public static partial class AdminSeasonManagementEndpoints
candidate.SeasonId,
request.CategoryId,
candidateId,
candidate.StreamerIdentityId,
normalizedDisplayName,
normalizedChannelSlug,
normalizedAcceptanceStatus,
@@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints
Description = request.Description.Trim(),
SortOrder = request.SortOrder,
MaxNomineesPerUser = request.MaxNomineesPerUser,
ViewerRangeMin = request.ViewerRangeMin,
ViewerRangeMax = request.ViewerRangeMax,
};
db.Categories.Add(category);
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
category.Description = request.Description.Trim();
category.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
category.ViewerRangeMin = request.ViewerRangeMin;
category.ViewerRangeMax = request.ViewerRangeMax;
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -0,0 +1,499 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> UpdateSeasonSubcategoryTemplates(
HttpContext context,
int seasonId,
UpdateSeasonSubcategoryTemplatesRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var validationError = ValidateSubcategoryTemplatesRequest(request);
if (validationError is not null)
{
return validationError;
}
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 categories = await db.Categories
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToListAsync(context.RequestAborted);
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
var blockedRemovals = await FindBlockedSubcategoryRemovalsAsync(db, categories, templates, context.RequestAborted);
if (blockedRemovals.Length > 0)
{
var firstBlocked = blockedRemovals[0];
return Results.BadRequest(new
{
message = $"Unterkategorie \"{firstBlocked.SubcategoryName}\" kann nicht entfernt werden, weil darunter noch {firstBlocked.CandidateCount} Kandidaten und {firstBlocked.NominationCount} Nominierungen haengen.",
blockedSubcategories = blockedRemovals,
});
}
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
SyncCategoryGroupsToTemplates(db, season, categories, templates);
adminAuditService.AddEntry(
session.TwitchUserId,
"category-templates.update",
"season",
seasonId.ToString(),
$"Unterkategorien für {season.Year} wurden aktualisiert.",
new { seasonId, templateCount = templates.Length },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, seasonId, templateCount = templates.Length });
}
private static async Task<IResult> CreateCategoryGroup(
HttpContext context,
int seasonId,
UpsertCategoryGroupRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var validationError = ValidateCategoryGroupRequest(request);
if (validationError is not null)
{
return validationError;
}
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 categories = await db.Categories
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToListAsync(context.RequestAborted);
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
if (templates.Length == 0)
{
return Results.BadRequest(new { message = "Lege zuerst mindestens eine globale Unterkategorie an." });
}
var groupName = request.GroupName.Trim();
if (categories.Any(item => string.Equals(item.GroupName, groupName, StringComparison.OrdinalIgnoreCase)))
{
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
}
for (var index = 0; index < templates.Length; index += 1)
{
categories.Add(new Category
{
SeasonId = seasonId,
GroupName = groupName,
Name = templates[index].Name,
Slug = BuildCategorySlug(groupName, templates[index].Slug),
Description = request.Description.Trim(),
SortOrder = request.SortOrder,
MaxNomineesPerUser = request.MaxNomineesPerUser,
ViewerRangeMin = templates[index].ViewerRangeMin,
ViewerRangeMax = templates[index].ViewerRangeMax,
});
}
foreach (var category in categories.Where(item => item.Id == 0))
{
db.Categories.Add(category);
}
SyncCategoryGroupsToTemplates(db, season, categories, templates);
adminAuditService.AddEntry(
session.TwitchUserId,
"category-group.create",
"season",
seasonId.ToString(),
$"Hauptkategorie {groupName} wurde angelegt.",
new { seasonId, groupName, request.SortOrder, request.MaxNomineesPerUser },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, seasonId, groupName });
}
private static async Task<IResult> UpdateCategoryGroup(
HttpContext context,
int seasonId,
string groupName,
UpsertCategoryGroupRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var validationError = ValidateCategoryGroupRequest(request);
if (validationError is not null)
{
return validationError;
}
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 categories = await db.Categories
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToListAsync(context.RequestAborted);
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
var normalizedCurrentName = groupName.Trim();
var groupCategories = categories
.Where(item => string.Equals(item.GroupName, normalizedCurrentName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (groupCategories.Length == 0)
{
return Results.NotFound();
}
var targetName = request.GroupName.Trim();
if (!string.Equals(normalizedCurrentName, targetName, StringComparison.OrdinalIgnoreCase)
&& categories.Any(item => string.Equals(item.GroupName, targetName, StringComparison.OrdinalIgnoreCase)))
{
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
}
foreach (var category in groupCategories)
{
category.GroupName = targetName;
category.Description = request.Description.Trim();
category.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
}
SyncCategoryGroupsToTemplates(db, season, categories, templates);
adminAuditService.AddEntry(
session.TwitchUserId,
"category-group.update",
"season",
seasonId.ToString(),
$"Hauptkategorie {normalizedCurrentName} wurde aktualisiert.",
new { seasonId, from = normalizedCurrentName, to = targetName, request.SortOrder, request.MaxNomineesPerUser },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, seasonId, groupName = targetName });
}
private static async Task<IResult> DeleteCategoryGroup(
HttpContext context,
int seasonId,
string groupName,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var normalizedGroupName = groupName.Trim();
var categories = await db.Categories
.Where(item => item.SeasonId == seasonId && item.GroupName.ToLower() == normalizedGroupName.ToLower())
.ToListAsync(context.RequestAborted);
if (categories.Count == 0)
{
return Results.NotFound();
}
var categoryIds = categories.Select(item => item.Id).ToArray();
var candidates = await db.Candidates
.Where(item => categoryIds.Contains(item.CategoryId))
.ToArrayAsync(context.RequestAborted);
if (candidates.Length > 0)
{
db.Candidates.RemoveRange(candidates);
}
db.Categories.RemoveRange(categories);
adminAuditService.AddEntry(
session.TwitchUserId,
"category-group.delete",
"season",
seasonId.ToString(),
$"Hauptkategorie {normalizedGroupName} wurde gelöscht.",
new { seasonId, removedCategories = categories.Count, removedCandidates = candidates.Length },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, seasonId, groupName = normalizedGroupName });
}
private static void SyncCategoryGroupsToTemplates(
AwardsDbContext db,
Season season,
List<Category> categories,
SeasonSubcategoryTemplateSetting[] templates)
{
var orderedGroups = categories
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var items = group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList();
var sample = items[0];
return new
{
GroupName = sample.GroupName.Trim(),
Description = sample.Description.Trim(),
SortOrder = items.Min(item => item.SortOrder),
MaxNomineesPerUser = sample.MaxNomineesPerUser,
Items = items,
};
})
.OrderBy(group => group.SortOrder)
.ThenBy(group => group.GroupName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var nextSortOrder = 1;
foreach (var group in orderedGroups)
{
var usedCategories = new HashSet<Category>();
for (var index = 0; index < templates.Length; index += 1)
{
var template = templates[index];
var category = FindReusableCategoryForTemplate(group.Items, template, group.GroupName, usedCategories)
?? new Category { SeasonId = season.Id };
usedCategories.Add(category);
category.GroupName = group.GroupName;
category.Name = template.Name;
category.Slug = BuildCategorySlug(group.GroupName, template.Slug);
category.Description = group.Description;
category.MaxNomineesPerUser = group.MaxNomineesPerUser;
category.ViewerRangeMin = template.ViewerRangeMin;
category.ViewerRangeMax = template.ViewerRangeMax;
category.SortOrder = nextSortOrder++;
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
{
db.Categories.Add(category);
categories.Add(category);
}
}
foreach (var staleCategory in FindStaleCategoriesAfterTemplateSync(group.Items, templates, group.GroupName))
{
RemoveCategoryWithCandidates(db, staleCategory);
categories.Remove(staleCategory);
}
}
}
private static void RemoveCategoryWithCandidates(AwardsDbContext db, Category category)
{
if (category.Id > 0)
{
var candidates = db.Candidates.Where(item => item.CategoryId == category.Id).ToArray();
if (candidates.Length > 0)
{
db.Candidates.RemoveRange(candidates);
}
}
db.Categories.Remove(category);
}
private static async Task<BlockedSubcategoryRemoval[]> FindBlockedSubcategoryRemovalsAsync(
AwardsDbContext db,
List<Category> categories,
SeasonSubcategoryTemplateSetting[] templates,
CancellationToken cancellationToken)
{
var staleCategories = categories
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
.SelectMany(group => FindStaleCategoriesAfterTemplateSync(
group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(),
templates,
group.Key))
.Where(item => item.Id > 0)
.ToArray();
if (staleCategories.Length == 0)
{
return [];
}
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
var candidateCounts = await db.Candidates
.Where(item => staleCategoryIds.Contains(item.CategoryId))
.GroupBy(item => item.CategoryId)
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
var nominationCounts = await db.Nominations
.Where(item =>
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value)))
.GroupBy(item => item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)
? item.CategoryId!.Value
: item.SuggestedCategoryId!.Value)
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
return staleCategories
.Select(category => new BlockedSubcategoryRemoval(
category.GroupName,
category.Name,
category.Slug,
candidateCounts.GetValueOrDefault(category.Id),
nominationCounts.GetValueOrDefault(category.Id)))
.Where(item => item.CandidateCount > 0 || item.NominationCount > 0)
.OrderBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.SubcategoryName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static Category? FindReusableCategoryForTemplate(
List<Category> categories,
SeasonSubcategoryTemplateSetting template,
string groupName,
HashSet<Category>? usedCategories = null)
{
usedCategories ??= [];
var expectedSlug = BuildCategorySlug(groupName, template.Slug);
var normalizedTemplateSlug = SeasonSubcategoryTemplateSettings.Slugify(template.Slug);
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.Slug, expectedSlug, StringComparison.OrdinalIgnoreCase))
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.Slug, normalizedTemplateSlug, StringComparison.OrdinalIgnoreCase))
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& item.Slug.EndsWith($"-{normalizedTemplateSlug}", StringComparison.OrdinalIgnoreCase))
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
}
private static Category[] FindStaleCategoriesAfterTemplateSync(
List<Category> categories,
SeasonSubcategoryTemplateSetting[] templates,
string groupName)
{
var usedCategories = new HashSet<Category>();
foreach (var template in templates)
{
var reusableCategory = FindReusableCategoryForTemplate(categories, template, groupName, usedCategories);
if (reusableCategory is not null)
{
usedCategories.Add(reusableCategory);
}
}
return categories
.Where(item => !usedCategories.Contains(item))
.ToArray();
}
private static IResult? ValidateSubcategoryTemplatesRequest(UpdateSeasonSubcategoryTemplatesRequest request)
{
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
if (templates.Length == 0)
{
return Results.BadRequest(new { message = "Mindestens eine Unterkategorie ist erforderlich." });
}
var duplicateNames = templates
.GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
.Any(group => group.Count() > 1);
if (duplicateNames)
{
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Namen mehrfach verwenden." });
}
var duplicateSlugs = templates
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
.Any(group => group.Count() > 1);
if (duplicateSlugs)
{
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Slug mehrfach verwenden." });
}
foreach (var template in templates)
{
if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 120)
{
return Results.BadRequest(new { message = "Unterkategorie-Name ist erforderlich und muss unter 120 Zeichen bleiben." });
}
if (string.IsNullOrWhiteSpace(template.Slug) || template.Slug.Length > 120)
{
return Results.BadRequest(new { message = "Unterkategorie-Slug ist erforderlich und muss unter 120 Zeichen bleiben." });
}
if (template.ViewerRangeMax is not null
&& template.ViewerRangeMin is not null
&& template.ViewerRangeMax < template.ViewerRangeMin)
{
return Results.BadRequest(new { message = "Viewer-Range Ende muss groesser oder gleich dem Start sein." });
}
}
return null;
}
private static IResult? ValidateCategoryGroupRequest(UpsertCategoryGroupRequest request)
{
var groupName = request.GroupName.Trim();
var description = request.Description.Trim();
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > 80)
{
return Results.BadRequest(new { message = "Hauptkategorie ist erforderlich und muss unter 80 Zeichen bleiben." });
}
if (description.Length > 400)
{
return Results.BadRequest(new { message = "Beschreibung muss unter 400 Zeichen bleiben." });
}
if (request.SortOrder is < 1 or > 200)
{
return Results.BadRequest(new { message = "Die Reihenfolge muss zwischen 1 und 200 liegen." });
}
if (request.MaxNomineesPerUser is < 1 or > 10)
{
return Results.BadRequest(new { message = "Das Nominierungs-Limit muss zwischen 1 und 10 liegen." });
}
return null;
}
private sealed record BlockedSubcategoryRemoval(
string GroupName,
string SubcategoryName,
string Slug,
int CandidateCount,
int NominationCount);
private static string BuildCategorySlug(string groupName, string templateSlug)
{
var groupSlug = SeasonSubcategoryTemplateSettings.Slugify(groupName);
var detailSlug = SeasonSubcategoryTemplateSettings.Slugify(templateSlug);
return string.IsNullOrWhiteSpace(groupSlug) ? detailSlug : $"{groupSlug}-{detailSlug}";
}
}
@@ -28,6 +28,10 @@ public static partial class AdminSeasonManagementEndpoints
}
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings));
var season = new Season
{
@@ -45,6 +49,8 @@ public static partial class AdminSeasonManagementEndpoints
ReviewEndsAt = request.ReviewEndsAt,
ShowDate = request.ShowDate,
ShowStartsAt = request.ShowStartsAt,
SubcategoryTemplatesJson = "[]",
WorkflowRulesJson = initialWorkflowRulesJson,
};
db.Seasons.Add(season);
@@ -72,6 +78,13 @@ public static partial class AdminSeasonManagementEndpoints
.ToArrayAsync(context.RequestAborted);
copiedCategoryCount = sourceCategories.Length;
var sourceSeason = await db.Seasons
.AsNoTracking()
.FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson;
season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson)
? initialWorkflowRulesJson
: sourceSeason.WorkflowRulesJson;
foreach (var category in sourceCategories)
{
db.Categories.Add(new Category
@@ -83,6 +96,8 @@ public static partial class AdminSeasonManagementEndpoints
Description = category.Description,
SortOrder = category.SortOrder,
MaxNomineesPerUser = category.MaxNomineesPerUser,
ViewerRangeMin = category.ViewerRangeMin,
ViewerRangeMax = category.ViewerRangeMax,
});
}
}
+380 -7
View File
@@ -1,5 +1,6 @@
using Backend.Contracts;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
return Results.NotFound();
}
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
var trackingRules = TrackingRulesSettings.Read(settings);
var candidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
@@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints
.Select(item => new AdminCandidateItemDto(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
item.Platform,
item.NominationTally,
item.AcceptanceStatus,
item.AcceptanceNote,
item.ClipCompilationUrl,
@@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
})
.ToArrayAsync();
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
season,
categoryRows.Select(category => new Backend.Domain.Category
{
GroupName = category.GroupName,
Name = category.Name,
Slug = category.Slug,
SortOrder = category.SortOrder,
ViewerRangeMin = category.ViewerRangeMin,
ViewerRangeMax = category.ViewerRangeMax,
}));
var visibleCategoryRows = categoryRows
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
new Backend.Domain.Category
{
GroupName = category.GroupName,
Name = category.Name,
Slug = category.Slug,
SortOrder = category.SortOrder,
ViewerRangeMin = category.ViewerRangeMin,
ViewerRangeMax = category.ViewerRangeMax,
},
subcategoryTemplateSettings))
.ToArray();
var categories = categoryRows
var categories = visibleCategoryRows
.Select(category => new AdminCategoryItemDto(
category.Id,
category.GroupName,
@@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
.ToArray();
var pendingNominations = await db.Nominations
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
var pendingNominationRows = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
.OrderByDescending(item => item.CreatedAt)
.Select(item => new AdminNominationReviewItemDto(
.Select(item => new AdminNominationRow(
item.Id,
item.CategoryId,
item.Category.Name,
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
item.CategoryId != null ? item.Category!.Name : null,
item.SubmittedByTwitchId,
item.CandidateText ?? string.Empty,
item.StreamUrl,
item.ResolvedChannel,
item.ResolvedPlatform,
item.AvgViewers,
item.HoursStreamed,
item.HoursWatched,
item.PeakViewers,
item.FollowersGained,
item.SuggestedCategoryId,
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
item.StreamerIdentityId,
item.TrackerStatus,
item.TrackerCheckedAt,
item.TrackingReviewStatus,
item.TrackingFlagsJson,
item.TrackingReviewNote,
item.TrackingReviewedByTwitchId,
item.TrackingReviewedAt,
item.Status,
item.CreatedAt,
item.CandidateId,
@@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
item.ReviewedAt))
.ToArrayAsync();
var reviewedNominations = await db.Nominations
var pendingNominations = pendingNominationRows
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
.ToArray();
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
var reviewedNominationRows = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
.Select(item => new AdminNominationReviewItemDto(
.Select(item => new AdminNominationRow(
item.Id,
item.CategoryId,
item.Category.Name,
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
item.CategoryId != null ? item.Category!.Name : null,
item.SubmittedByTwitchId,
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
item.StreamUrl,
item.ResolvedChannel,
item.ResolvedPlatform,
item.AvgViewers,
item.HoursStreamed,
item.HoursWatched,
item.PeakViewers,
item.FollowersGained,
item.SuggestedCategoryId,
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
item.StreamerIdentityId,
item.TrackerStatus,
item.TrackerCheckedAt,
item.TrackingReviewStatus,
item.TrackingFlagsJson,
item.TrackingReviewNote,
item.TrackingReviewedByTwitchId,
item.TrackingReviewedAt,
item.Status,
item.CreatedAt,
item.CandidateId,
@@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
item.ReviewedAt))
.ToArrayAsync();
var reviewedNominations = reviewedNominationRows
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
.ToArray();
var resultItems = await db.Results
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
@@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints
item.CategoryId,
item.Category.Name,
item.CandidateId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
item.Candidate.Platform))
@@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints
season.ReviewEndsAt,
season.ShowDate,
season.ShowStartsAt,
subcategoryTemplates,
categories,
candidates,
pendingNominations,
pendingNominationGroups,
reviewedNominations,
settings?.TrackingReviewNotes ?? string.Empty,
trackingRules.Source.ShowManualReviewNotesInReview,
resultItems,
clipSubmissions));
}
private sealed record AdminNominationRow(
int Id,
int? CategoryId,
string? CategoryGroupName,
string? CategoryName,
string SubmittedByTwitchId,
string CandidateText,
string? StreamUrl,
string? ResolvedChannel,
string? ResolvedPlatform,
int? AvgViewers,
int? HoursStreamed,
int? HoursWatched,
int? PeakViewers,
int? FollowersGained,
int? SuggestedCategoryId,
string? SuggestedCategoryName,
int? StreamerIdentityId,
string TrackerStatus,
DateTimeOffset? TrackerCheckedAt,
string TrackingReviewStatus,
string TrackingFlagsJson,
string? TrackingReviewNote,
string? TrackingReviewedByTwitchId,
DateTimeOffset? TrackingReviewedAt,
string Status,
DateTimeOffset CreatedAt,
int? CandidateId,
string? CandidateDisplayName,
string? ReviewNote,
string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt);
private static AdminNominationReviewItemDto ToNominationReviewItem(
AdminNominationRow item,
IEnumerable<dynamic> categoryRows,
TrackingRulesConfiguration trackingRules)
{
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
.Select(ToTrackingFlagHitDto)
.ToArray();
return new(
item.Id,
item.CategoryId,
ResolveCategoryGroupName(item, categoryRows),
ResolveCategoryName(item, categoryRows),
item.SubmittedByTwitchId,
item.CandidateText,
item.StreamUrl,
item.ResolvedChannel,
item.ResolvedPlatform,
item.AvgViewers,
item.SuggestedCategoryId,
item.SuggestedCategoryName,
item.StreamerIdentityId,
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
item.TrackerCheckedAt,
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
trackingFlags.Any(flag => flag.RequiresManualReview),
trackingFlags,
BuildTrackingMetricStateDtos(item, trackingRules),
item.TrackingReviewNote,
item.TrackingReviewedByTwitchId,
item.TrackingReviewedAt,
item.Status,
item.CreatedAt,
item.CandidateId,
item.CandidateDisplayName,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt);
}
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
IEnumerable<AdminNominationRow> rows,
IEnumerable<dynamic> categoryRows,
TrackingRulesConfiguration trackingRules) =>
rows
.GroupBy(item => new
{
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
IdentityKey = item.StreamerIdentityId.HasValue
? $"identity:{item.StreamerIdentityId.Value}"
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
})
.Select(group =>
{
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
var representative = ordered
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
.ThenByDescending(item => item.AvgViewers.HasValue)
.First();
var trackerStatus = ResolveGroupTrackerStatus(ordered);
var trackingFlags = ordered
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
.GroupBy(item => item.Key)
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
.ToArray();
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
return new AdminNominationReviewGroupDto(
representative.Id,
ordered.Select(item => item.Id).ToArray(),
ResolveCategoryGroupName(representative, categoryRows),
ResolveNominationDisplayName(representative),
representative.StreamUrl,
representative.ResolvedChannel,
representative.ResolvedPlatform,
representative.AvgViewers,
representative.SuggestedCategoryId,
representative.SuggestedCategoryName,
representative.StreamerIdentityId,
trackerStatus,
representative.TrackerCheckedAt,
ResolveGroupTrackingReviewStatus(ordered),
requiresManualReview,
trackingFlags,
BuildTrackingMetricStateDtos(representative, trackingRules),
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
ordered.Max(item => item.TrackingReviewedAt),
ordered.Length,
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
ordered.First().CreatedAt,
ordered.Last().CreatedAt);
})
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
.ThenByDescending(item => item.RequiresManualReview)
.ThenByDescending(item => item.NominationTally)
.ThenByDescending(item => item.UniqueSubmitterCount)
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
.ThenByDescending(item => item.AvgViewers ?? -1)
.ThenByDescending(item => item.LastSubmittedAt)
.ToArray();
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
item.ResolvedChannel
?? item.CandidateText
?? item.StreamUrl
?? "Name im Review festlegen";
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
{
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
?? rows.FirstOrDefault()?.TrackerStatus
?? "pending";
}
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
{
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
?? rows.FirstOrDefault()?.TrackingReviewStatus
?? "clear";
}
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
{
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
{
return item.CategoryGroupName.Trim();
}
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
return category?.GroupName ?? "Unbekannte Hauptkategorie";
}
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
{
if (!string.IsNullOrWhiteSpace(item.CategoryName))
{
return item.CategoryName.Trim();
}
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
}
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
new(
flag.Key,
flag.Label,
flag.Severity,
flag.Description,
flag.RequiresManualReview,
flag.BlocksApproval,
flag.AdminNoteRequiredOnOverride);
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
AdminNominationRow row,
TrackingRulesConfiguration trackingRules) =>
trackingRules.ImportantMetrics
.Concat(trackingRules.OptionalMetrics)
.Where(metric => metric.Enabled && metric.ShowInReview)
.Select(metric => new AdminTrackingMetricStateDto(
metric.Key,
metric.Label,
metric.RequiredForAutoClassification,
metric.SourceSupport,
MetricPresent(metric, row),
MetricValue(metric, row),
metric.Description,
metric.WindowKey,
TrackingRulesSettings.WindowLabel(metric.WindowKey),
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
.ToArray();
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
{
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
{
return false;
}
return metric.Key switch
{
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
_ => false,
};
}
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
{
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
{
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
}
return metric.Key switch
{
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen",
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
_ => "manuell",
};
}
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
{
var parts = new List<string>();
if (metric.TopCount.HasValue)
{
parts.Add($"Top {metric.TopCount.Value}");
}
if (metric.MinPrimaryCategorySharePercent.HasValue)
{
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
}
if (metric.MinPrimaryCategoryHours.HasValue)
{
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
}
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
{
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
}
if (metric.IgnoredCategories.Length > 0)
{
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
}
return parts.Count > 0
? string.Join(" · ", parts)
: "Top-Kategorien manuell pruefen";
}
}
@@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("DeleteAdminCategory")
.WithOpenApi();
group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("UpdateAdminSeasonSubcategoryTemplates")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("CreateAdminCategoryGroup")
.WithOpenApi();
group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("UpdateAdminCategoryGroup")
.WithOpenApi();
group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("DeleteAdminCategoryGroup")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("CreateAdminCandidate")
@@ -56,11 +72,11 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("DeleteAdminResult")
.WithOpenApi();
group.MapGet("/workflow-rules", GetWorkflowRules)
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("GetAdminWorkflowRules")
.WithOpenApi();
group.MapPut("/workflow-rules", UpdateWorkflowRules)
group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("UpdateAdminWorkflowRules")
.WithOpenApi();
@@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints
private sealed record CandidateRuleSnapshot(
int Id,
int CategoryId,
int? StreamerIdentityId,
string DisplayName,
string ChannelSlug,
string AcceptanceStatus);
private sealed record CandidateReadinessSnapshot(
int CategoryId,
int? StreamerIdentityId,
string DisplayName,
string ChannelSlug,
string AcceptanceStatus,
string? ClipCompilationUrl);
private sealed record WinnerReadinessSnapshot(
int CategoryId,
int? StreamerIdentityId,
string DisplayName,
string ChannelSlug,
string? ClipCompilationUrl);
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
{
if (request.Year < 2020 || request.Year > 2100)
@@ -145,13 +161,16 @@ public static partial class AdminSeasonManagementEndpoints
});
}
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken)
{
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
return WorkflowRuleSettings.Read(settings);
return WorkflowRuleSettings.Read(season, settings);
}
private static IResult CreateWorkflowRuleError(string message) =>
@@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints
int seasonId,
int categoryId,
int? existingCandidateId,
int? streamerIdentityId,
string displayName,
string channelSlug,
string acceptanceStatus,
@@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints
return null;
}
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
var rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
@@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints
.Select(item => new CandidateRuleSnapshot(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
item.AcceptanceStatus))
@@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints
{
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
var appearanceCount = existingCandidates.Count(item =>
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
if (appearanceCount >= appearancesRule.Limit)
{
return CreateWorkflowRuleError(
@@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints
bool isCurrent,
CancellationToken cancellationToken)
{
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
if (season is null)
{
return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."];
}
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
@@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints
return [];
}
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances);
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
var categoryIds = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
@@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints
if (needsCandidateReadiness && categoryIds.Length > 0)
{
var categoriesWithCandidates = await db.Candidates
var candidateSnapshots = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => new CandidateReadinessSnapshot(
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
item.AcceptanceStatus,
item.ClipCompilationUrl))
.ToArrayAsync(cancellationToken);
var activeCandidates = candidateSnapshots
.Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
.ToArray();
var categoriesWithCandidates = activeCandidates
.Select(item => item.CategoryId)
.Distinct()
.CountAsync(cancellationToken);
.Count();
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
if (emptyCategories > 0)
{
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten.");
}
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
{
var identityOverflow = activeCandidates
.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 > appearancesRule.Limit)
.OrderByDescending(item => item.Count)
.FirstOrDefault();
if (identityOverflow is not null)
{
issues.Add(
$"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
}
}
}
if (needsWinnerReadiness && categoryIds.Length > 0)
{
if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now))
{
issues.Add("Die Award Show liegt noch nicht in der Vergangenheit.");
}
var categoriesWithResults = await db.Results
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
@@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints
{
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
}
var resultSnapshots = await db.Results
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => new WinnerReadinessSnapshot(
item.CategoryId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
item.Candidate.ClipCompilationUrl))
.ToArrayAsync(cancellationToken);
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.");
}
}
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)
{
return $"identity:{streamerIdentityId.Value}";
}
return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
}
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
{
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
@@ -364,6 +484,21 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
}
if (request.ViewerRangeMin is < 0 or > 100000)
{
return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." });
}
if (request.ViewerRangeMax is < 0 or > 100000)
{
return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." });
}
if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin)
{
return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." });
}
return null;
}
@@ -36,7 +36,7 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
}
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
@@ -56,11 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
.Select(item => new
{
item.CategoryId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
})
.ToArrayAsync(context.RequestAborted);
var existingWinnerCount = existingWinnerIdentities.Count(item =>
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
if (existingWinnerCount >= winnerPlacementsRule.Limit)
+342 -3
View File
@@ -41,6 +41,22 @@ public static class AdminSiteSettingsEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminOptionalFeatureSettings")
.WithOpenApi();
group.MapGet("/tracking-rules", GetTrackingRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("GetAdminTrackingRules")
.WithOpenApi();
group.MapPut("/tracking-rules", UpdateTrackingRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminTrackingRules")
.WithOpenApi();
group.MapPut("/tracking-rules/source", UpdateTrackingSource)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminTrackingSource")
.WithOpenApi();
group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminTrackingReviewNotes")
.WithOpenApi();
return group;
}
@@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName,
settings.HostTagline,
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
@@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings)));
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]"));
}
private static async Task<IResult> UpdateSiteSettings(
@@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
@@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints
settings.ShowactsContent = request.ShowactsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints
socialLinks = [];
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|| !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out 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)
@@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints
normalizedUrls = new PublicSiteUrlSettings
{
NewsletterUrl = newsletterUrl,
ShareXUrl = shareXUrl,
ShareDiscordUrl = shareDiscordUrl,
ImprintUrl = imprintUrl,
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
@@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints
private sealed class PublicSiteUrlSettings
{
public string NewsletterUrl { get; set; } = string.Empty;
public string ShareXUrl { get; set; } = string.Empty;
public string ShareDiscordUrl { 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;
@@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
}
private static async Task<IResult> GetTrackingRules(AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(ToTrackingRulesResponse(settings));
}
private static async Task<IResult> UpdateTrackingRules(
HttpContext context,
UpdateTrackingRulesRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService,
NominationTrackingReviewService trackingReviewService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var before = ToTrackingRulesResponse(settings);
var applyResult = ApplyTrackingRulesRequest(settings, request);
if (applyResult is not null)
{
return applyResult;
}
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
var after = ToTrackingRulesResponse(settings);
adminAuditService.AddEntry(
session.TwitchUserId,
"tracking-rules.update",
"site-settings",
settings.Id.ToString(),
"Tracking Rules wurden aktualisiert.",
new
{
sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl,
manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes,
importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled),
optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled),
flagCount = after.Flags.Count(item => item.Enabled),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(after);
}
private static async Task<IResult> UpdateTrackingSource(
HttpContext context,
UpdateTrackingSourceRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService,
NominationTrackingReviewService trackingReviewService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
var before = ToTrackingRulesResponse(settings);
var rules = TrackingRulesSettings.Read(settings);
var updatedRules = rules with
{
Source = new TrackingSourceSetting(
TrackingRulesSettings.ProviderKey,
normalizedBaseUrl,
request.Source?.NotesSummary ?? rules.Source.NotesSummary,
request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview),
};
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules);
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
var after = ToTrackingRulesResponse(settings);
adminAuditService.AddEntry(
session.TwitchUserId,
"tracking-source.update",
"site-settings",
settings.Id.ToString(),
"Tracking Source wurde aktualisiert.",
new
{
beforeBaseUrl = before.Source.BaseUrl,
afterBaseUrl = after.Source.BaseUrl,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(after);
}
private static async Task<IResult> UpdateTrackingReviewNotes(
HttpContext context,
UpdateTrackingReviewNotesRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var beforeNotes = settings.TrackingReviewNotes ?? string.Empty;
var before = ToTrackingRulesResponse(settings);
var rules = TrackingRulesSettings.Read(settings);
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with
{
Source = rules.Source with
{
ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview,
},
});
var after = ToTrackingRulesResponse(settings);
adminAuditService.AddEntry(
session.TwitchUserId,
"tracking-review-notes.update",
"site-settings",
settings.Id.ToString(),
"Tracking Review Notes wurden aktualisiert.",
new
{
beforeLength = beforeNotes.Length,
afterLength = settings.TrackingReviewNotes.Length,
beforeShowInReview = before.Source.ShowManualReviewNotesInReview,
afterShowInReview = after.Source.ShowManualReviewNotesInReview,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(after);
}
private static async Task<IResult> UpdateOptionalFeatureSettings(
HttpContext context,
UpdateOptionalFeatureSettingsRequest request,
@@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints
}
var before = ToOptionalFeatureSettingsResponse(settings);
var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt);
if (scheduleValidationError is not null)
{
return Results.BadRequest(new { message = scheduleValidationError });
}
var disabledMessage = NormalizeOptionalFeatureText(
request.ClipSubmissionDisabledMessage,
FallbackClipSubmissionDisabledMessage,
@@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
settings.ClipSubmissionDisabledMessage = disabledMessage;
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt;
settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt;
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
settings.SponsorsVisible = request.SponsorsVisible;
@@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints
? FallbackClipSubmissionDisabledMessage
: settings.ClipSubmissionDisabledMessage,
settings.ShowactApplicationsEnabled,
settings.ShowactApplicationStartsAt,
settings.ShowactApplicationEndsAt,
ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)),
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: settings.ShowactApplicationDisabledMessage,
@@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt);
AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt);
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
return changes.ToArray();
@@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
return Results.Ok(new AdminOperationalSettingsResponse(
usesDatabaseDemo,
@@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints
twitchSettings.ClientSecretSet,
twitchSettings.RedirectUri,
twitchSettings.Scope,
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
@@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
var twitchScope = request.TwitchScope.Trim();
if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours)
{
return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." });
}
var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours);
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
@@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints
settings.TwitchClientSecret,
settings.TwitchRedirectUri,
settings.TwitchScope);
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
? newPassword
@@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints
"operational-settings.update",
"site-settings",
settings.Id.ToString(),
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
new
{
settings.DemoLoginEnabled,
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
settings.SessionIdleTimeoutHours,
settings.MaintenanceModeEnabled,
changes,
},
@@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints
HasEffectiveTwitchClientSecret(settings, configuration),
settings.TwitchRedirectUri,
settings.TwitchScope,
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
@@ -576,6 +781,7 @@ public static class AdminSiteSettingsEndpoints
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
{
@@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints
bool TwitchClientSecretSet,
string TwitchRedirectUri,
string TwitchScope,
int SessionIdleTimeoutHours,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
@@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints
string RedirectUri,
string Scope,
bool Configured);
private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request)
{
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
var currentRules = TrackingRulesSettings.Read(settings);
var configuration = new TrackingRulesConfiguration(
new TrackingSourceSetting(
TrackingRulesSettings.ProviderKey,
normalizedBaseUrl,
request.Source?.NotesSummary ?? currentRules.Source.NotesSummary,
request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview),
(request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
(request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
(request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray());
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration);
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
return null;
}
private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings)
{
var rules = TrackingRulesSettings.Read(settings);
return new AdminTrackingRulesResponse(
new AdminTrackingSourceDto(
rules.Source.ProviderKey,
"TwitchTracker Basic API",
TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl),
rules.Source.NotesSummary,
rules.Source.ShowManualReviewNotesInReview),
rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(),
settings.TrackingReviewNotes ?? string.Empty);
}
private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) =>
new(
rule.Key,
rule.Label,
rule.Enabled,
rule.SourceSupport,
rule.Description,
rule.RequiredForAutoClassification,
rule.ShowInReview,
rule.ShowInAdminSummary,
rule.ManualOverrideAllowed,
rule.WindowKey,
rule.AutoSupportedWindowKeys,
rule.ProviderFieldKey,
rule.TopCount,
rule.MinPrimaryCategorySharePercent,
rule.MinPrimaryCategoryHours,
rule.MaxDistinctCategoriesBeforeFlag,
rule.IgnoredCategories,
rule.MatchAwardCategoryAgainstTopCategories,
rule.FlagIfAwardCategoryNotInTopX,
rule.FlagIfCategorySpreadTooWide,
rule.FlagIfNoCategoryContextAvailable,
rule.MinValue,
rule.MaxValue);
private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) =>
new(
rule.Key,
rule.Label,
rule.Enabled,
rule.SourceSupport,
rule.Description,
rule.RequiredForAutoClassification,
rule.ShowInReview,
rule.ShowInAdminSummary,
rule.ManualOverrideAllowed,
TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d),
(rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(),
rule.TopCount,
rule.MinPrimaryCategorySharePercent,
rule.MinPrimaryCategoryHours,
rule.MaxDistinctCategoriesBeforeFlag,
(rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
rule.MatchAwardCategoryAgainstTopCategories,
rule.FlagIfAwardCategoryNotInTopX,
rule.FlagIfCategorySpreadTooWide,
rule.FlagIfNoCategoryContextAvailable,
rule.MinValue,
rule.MaxValue);
private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) =>
new(
rule.Key,
rule.Label,
rule.Enabled,
rule.Severity,
rule.Description,
rule.AutoTriggerEnabled,
rule.RequiresManualReview,
rule.BlocksApproval,
rule.AdminNoteRequiredOnOverride);
private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) =>
new(
rule.Key,
rule.Label,
rule.Enabled,
rule.Severity,
rule.Description,
rule.AutoTriggerEnabled,
rule.RequiresManualReview,
rule.BlocksApproval,
rule.AdminNoteRequiredOnOverride);
private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage)
{
normalizedValue = string.Empty;
errorMessage = string.Empty;
var trimmed = (rawValue ?? string.Empty).Trim();
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein.";
return false;
}
normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
return true;
}
}
+22 -14
View File
@@ -8,33 +8,41 @@ namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
private static async Task<IResult> GetWorkflowRules(int seasonId, AwardsDbContext db)
{
var settings = await db.SiteSettings
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(season, settings).Select(ToWorkflowRuleDto).ToArray()));
}
private static async Task<IResult> UpdateWorkflowRules(
HttpContext context,
int seasonId,
UpdateWorkflowRulesRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
if (season is null)
{
return Results.NotFound();
}
var before = WorkflowRuleSettings.Read(settings);
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
var before = WorkflowRuleSettings.Read(season, settings);
var mergedRules = WorkflowRuleSettings.Defaults
.Select(defaultRule =>
{
@@ -51,8 +59,8 @@ public static partial class AdminSeasonManagementEndpoints
})
.ToArray();
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
var after = WorkflowRuleSettings.Read(settings);
season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
var after = WorkflowRuleSettings.Read(season, settings);
var changes = after
.Select(rule =>
{
@@ -72,10 +80,10 @@ public static partial class AdminSeasonManagementEndpoints
adminAuditService.AddEntry(
session.TwitchUserId,
"workflow-rules.update",
"site-settings",
settings.Id.ToString(),
"Workflow-Regeln wurden aktualisiert.",
new { changes },
"season",
season.Id.ToString(),
$"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.",
new { seasonId = season.Id, season.Year, changes },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
+38 -19
View File
@@ -12,6 +12,7 @@ public static partial class AuthEndpoints
{
private static async Task<IResult> DemoLogin(
HttpContext context,
IHostEnvironment environment,
AwardsDbContext db,
IConfiguration configuration,
DemoLoginRequest request,
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
var password = request.Password ?? string.Empty;
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
var databaseDemoConfigured = settings is not null
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
&& settings.DemoLoginManagedByDatabase;
string twitchUserId;
string displayName;
bool credentialsMatch;
var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration);
var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
if (databaseDemoConfigured && settings is not null)
{
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
displayName = settings.DemoLoginDisplayName.Trim();
if (!credentialsMatch
&& environment.IsDevelopment()
&& IsDemoLoginEnabled(configuration)
&& !string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
&& !string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
&& !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
&& !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
{
credentialsMatch = LoginMatchesIdentifier(
login,
fallbackConfiguredLogin,
fallbackConfiguredEmail,
fallbackConfiguredTwitchUserId,
fallbackConfiguredDisplayName)
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
displayName = fallbackConfiguredDisplayName.Trim();
}
}
else
{
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
return Results.NotFound();
}
var configuredLogin = ReadDemoLoginIdentifier(configuration);
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
if (string.IsNullOrWhiteSpace(configuredLogin)
|| string.IsNullOrWhiteSpace(configuredPassword)
|| string.IsNullOrWhiteSpace(twitchUserId)
|| string.IsNullOrWhiteSpace(displayName))
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
{
return Results.Json(
new { message = "Demo login is not fully configured." },
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
credentialsMatch = LoginMatchesIdentifier(
login,
configuredLogin,
configuredEmail,
twitchUserId,
displayName)
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
twitchUserId = twitchUserId.Trim();
displayName = displayName.Trim();
fallbackConfiguredLogin,
fallbackConfiguredEmail,
fallbackConfiguredTwitchUserId,
fallbackConfiguredDisplayName)
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
displayName = fallbackConfiguredDisplayName.Trim();
}
if (!credentialsMatch)
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
}
private static bool IsDemoLoginEnabled(IConfiguration configuration)
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
}
}
+4 -1
View File
@@ -19,7 +19,7 @@ public static partial class AuthEndpoints
return Results.Unauthorized();
}
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
}
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
@@ -36,6 +36,7 @@ public static partial class AuthEndpoints
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
AwardsDbContext db,
IUserSessionService userSessionService,
UserSession session,
bool mustChangePassword = false,
CancellationToken cancellationToken = default)
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
var sessionRole = teamMember?.Role ?? session.Role;
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
return new(
session.SessionToken,
session.TwitchUserId,
teamMember?.DisplayName ?? session.DisplayName,
AdminRoles.Normalize(sessionRole),
permissionKeys,
sessionIdleTimeoutHours,
teamMember?.MustChangePassword ?? mustChangePassword,
teamMember?.Login,
teamMember?.BoundTwitchUserId,
+2 -2
View File
@@ -44,7 +44,7 @@ public static partial class AuthEndpoints
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> ChangePassword(
@@ -92,7 +92,7 @@ public static partial class AuthEndpoints
session.Role = AdminRoles.Normalize(member.Role);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
}
private static string BuildTeamSessionId(string login) =>
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
return Results.Ok(new TwitchBindingDisconnectResponse(
false,
false,
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
}
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
currentSessionUsesBoundTwitch,
currentSessionUsesBoundTwitch
? null
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
}
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
+298 -42
View File
@@ -1,3 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
@@ -8,6 +11,8 @@ namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
{
var season = await db.Seasons
@@ -45,13 +50,19 @@ public static partial class PublicEndpoints
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
}
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private static async Task<IResult> CreateShowactApplication(
HttpContext context,
CreateShowactApplicationRequest request,
AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
if (settings is null || !settings.ShowactApplicationsEnabled)
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
{
return Results.BadRequest(new
{
@@ -67,55 +78,137 @@ public static partial class PublicEndpoints
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
}
var artistName = NormalizePublicText(request.ArtistName, 120);
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
var performanceType = NormalizePublicText(request.PerformanceType, 80);
var description = NormalizePublicText(request.Description, 1000);
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
if (string.IsNullOrWhiteSpace(artistName))
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
{
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
}
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
var hasDynamicForm = schema.Count > 0;
if (hasDynamicForm)
{
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
Dictionary<string, string> responses;
try
{
responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson)
? new Dictionary<string, string>()
: JsonSerializer.Deserialize<Dictionary<string, string>>(request.FieldResponsesJson, ShowactJsonOptions) ?? new();
}
catch (JsonException)
{
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
}
MergeLegacyShowactFieldsIntoResponses(schema, responses, request);
var dynamicValidationError = ValidateShowactResponses(schema, responses);
if (dynamicValidationError is not null)
{
return Results.BadRequest(new { message = dynamicValidationError });
}
var artistNameField = schema.FirstOrDefault(f => f.IsArtistName);
var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt";
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
var performanceType = NormalizePublicText(request.PerformanceType, 80);
var description = NormalizePublicText(request.Description, 1000);
var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000);
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions);
var metadata = RequestMetadataReader.Read(context);
var application = new ShowactApplication
{
SeasonId = season.Id,
ArtistName = artistName[..Math.Min(artistName.Length, 120)],
ContactEmail = contactEmail,
ContactDiscord = contactDiscord,
PlatformUrl = platformUrl,
PerformanceType = performanceType,
Description = description,
TechnicalNotes = technicalNotes,
ReferenceUrl = referenceUrl,
FieldResponsesJson = fieldResponsesJson,
Status = "pending",
CreatedFromIp = metadata.ClientIp,
UserAgent = metadata.UserAgent,
CreatedAt = DateTimeOffset.UtcNow,
};
db.ShowactApplications.Add(application);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, applicationId = application.Id });
}
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
else
{
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
// Legacy fixed-fields path
var artistName = NormalizePublicText(request.ArtistName, 120);
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
var performanceType = NormalizePublicText(request.PerformanceType, 80);
var description = NormalizePublicText(request.Description, 1000);
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
if (string.IsNullOrWhiteSpace(artistName))
{
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
}
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
{
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
}
if (!IsBlankOrValidEmail(contactEmail))
{
return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." });
}
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
{
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
}
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
{
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
}
var metadata = RequestMetadataReader.Read(context);
var application = new ShowactApplication
{
SeasonId = season.Id,
ArtistName = artistName,
ContactEmail = contactEmail,
ContactDiscord = contactDiscord,
PlatformUrl = platformUrl,
PerformanceType = performanceType,
Description = description,
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
ReferenceUrl = referenceUrl,
Status = "pending",
CreatedFromIp = metadata.ClientIp,
UserAgent = metadata.UserAgent,
CreatedAt = DateTimeOffset.UtcNow,
};
db.ShowactApplications.Add(application);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, applicationId = application.Id });
}
}
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
{
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
}
var metadata = RequestMetadataReader.Read(context);
var application = new ShowactApplication
{
SeasonId = season.Id,
ArtistName = artistName,
ContactEmail = contactEmail,
ContactDiscord = contactDiscord,
PlatformUrl = platformUrl,
PerformanceType = performanceType,
Description = description,
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
ReferenceUrl = referenceUrl,
Status = "pending",
CreatedFromIp = metadata.ClientIp,
UserAgent = metadata.UserAgent,
CreatedAt = DateTimeOffset.UtcNow,
};
db.ShowactApplications.Add(application);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, applicationId = application.Id });
private sealed class ShowactFieldDefinition
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("type")] public string Type { get; set; } = "";
[JsonPropertyName("label")] public string Label { get; set; } = "";
[JsonPropertyName("required")] public bool Required { get; set; }
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
}
private static string NormalizePublicText(string? value, int maxLength)
@@ -128,4 +221,167 @@ public static partial class PublicEndpoints
string.IsNullOrWhiteSpace(value)
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
private static bool IsBlankOrValidEmail(string value) =>
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
private static bool IsBlankOrValidJsonObject(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
try
{
using var document = JsonDocument.Parse(value);
return document.RootElement.ValueKind == JsonValueKind.Object;
}
catch (JsonException)
{
return false;
}
}
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
{
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
.ToList() ?? [];
}
catch (JsonException)
{
return [];
}
}
private static string? ValidateShowactResponses(
IReadOnlyCollection<ShowactFieldDefinition> schema,
IDictionary<string, string> responses)
{
foreach (var field in schema)
{
var value = responses.TryGetValue(field.Id, out var rawValue)
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
: string.Empty;
responses[field.Id] = value;
if (field.Required && string.IsNullOrWhiteSpace(value))
{
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
? $"Bitte bestaetige: {field.Label}"
: $"{field.Label} ist erforderlich.";
}
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
{
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
}
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
{
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
}
}
return null;
}
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
{
if (field.MaxLength > 0)
{
return Math.Min(field.MaxLength, 2000);
}
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
}
private static void MergeLegacyShowactFieldsIntoResponses(
IReadOnlyCollection<ShowactFieldDefinition> schema,
IDictionary<string, string> responses,
CreateShowactApplicationRequest request)
{
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
MergeResponseValue(artistField, request.ArtistName, responses, 120);
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
var performanceField = schema.FirstOrDefault(field =>
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
var descriptionField = schema.FirstOrDefault(field =>
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
MergeResponseValue(descriptionField, request.Description, responses, 1000);
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
}
private static void MergeResponseValue(
ShowactFieldDefinition? field,
string? requestValue,
IDictionary<string, string> responses,
int maxLength)
{
if (field is null)
{
return;
}
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
{
return;
}
var normalized = NormalizePublicText(requestValue, maxLength);
if (!string.IsNullOrWhiteSpace(normalized))
{
responses[field.Id] = normalized;
}
}
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
{
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
return needles.Any(haystack.Contains);
}
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
{
var haystack = field.Id.ToLowerInvariant();
return needles.Any(haystack.Contains);
}
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
{
var haystack = field.Label.ToLowerInvariant();
return needles.Any(haystack.Contains);
}
}
+87 -19
View File
@@ -15,13 +15,14 @@ public static partial class PublicEndpoints
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
IRiskRuleService riskRuleService,
NominationEnrichmentService nominationEnrichmentService)
{
var submittedNominations = NormalizeSubmittedNominations(request);
if (submittedNominations.Length is 0 or > 3)
if (submittedNominations.Length == 0)
{
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
}
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
@@ -35,7 +36,7 @@ public static partial class PublicEndpoints
}
var distinctStreamUrls = submittedNominations
.Select(item => item.StreamUrl)
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -65,16 +66,37 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
}
var category = await db.Categories
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
if (category is null)
if (season is null)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
return Results.BadRequest(new { message = "The selected season does not exist." });
}
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted);
if (string.IsNullOrWhiteSpace(categoryGroupName))
{
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
}
var groupCategories = await db.Categories
.Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToArrayAsync(context.RequestAborted);
if (groupCategories.Length == 0)
{
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
}
var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories);
if (submittedNominations.Length > maxNomineesPerUser)
{
return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." });
}
var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
if (nominationSeasonResolution.Result is not null)
{
return nominationSeasonResolution.Result;
@@ -89,15 +111,16 @@ public static partial class PublicEndpoints
var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context);
var existingNominationCount = await db.Nominations.CountAsync(item =>
item.SeasonId == category.SeasonId
&& item.CategoryId == category.Id
item.SeasonId == season.Id
&& item.CategoryGroupName == categoryGroupName
&& item.SubmittedByTwitchId == submitterId
&& item.Status == "pending");
var records = submittedNominations.Select(nomination => new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
SeasonId = season.Id,
CategoryId = null,
CategoryGroupName = categoryGroupName,
SubmittedByTwitchId = submitterId,
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
@@ -106,6 +129,11 @@ public static partial class PublicEndpoints
CreatedAt = DateTimeOffset.UtcNow,
}).ToArray();
foreach (var record in records)
{
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
}
await db.Nominations.AddRangeAsync(records);
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
@@ -126,21 +154,21 @@ public static partial class PublicEndpoints
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
{
await riskFlagService.AddIfMissingAsync(
category.SeasonId,
season.Id,
submitterId,
"nomination",
"resubmitted_nomination",
resubmittedNominationRule.Severity,
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
requestMetadata,
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
context.RequestAborted);
}
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
category.SeasonId,
season.Id,
submitterId,
"nomination",
"rapid_nomination_burst",
@@ -152,7 +180,7 @@ public static partial class PublicEndpoints
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 });
}
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
@@ -191,4 +219,44 @@ public static partial class PublicEndpoints
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
.ToArray();
}
private static string NormalizeNominationUrlForCompare(string value) =>
value.Trim().TrimEnd('/').ToLowerInvariant();
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
{
var configuredLimit = groupCategories
.Select(item => item.MaxNomineesPerUser)
.Where(value => value > 0)
.DefaultIfEmpty(3)
.Max();
return Math.Clamp(configuredLimit, 1, 10);
}
private static async Task<string?> ResolveCategoryGroupNameAsync(
AwardsDbContext db,
int seasonId,
CreateNominationRequest request,
CancellationToken cancellationToken)
{
var categoryGroupName = request.CategoryGroupName?.Trim();
if (!string.IsNullOrWhiteSpace(categoryGroupName))
{
return await db.Categories
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
.Select(item => item.GroupName)
.FirstOrDefaultAsync(cancellationToken);
}
if (!request.CategoryId.HasValue)
{
return null;
}
return await db.Categories
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
.Select(item => item.GroupName)
.FirstOrDefaultAsync(cancellationToken);
}
}
+45 -12
View File
@@ -26,12 +26,18 @@ public static partial class PublicEndpoints
{
return Results.Problem("Site settings are missing.");
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
var canExposeCurrentSeasonWinners = CanExposeCurrentSeasonWinners(season.CurrentPhase);
var winnerPreviewRows = await db.Results
.AsNoTracking()
.Include(result => result.Season)
.Include(result => result.Candidate)
.Where(result => result.Season.Year < season.Year)
.Where(result =>
result.Season.Year < season.Year
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
.OrderByDescending(result => result.Season.Year)
.ThenBy(result => result.CategoryName)
.Take(8)
@@ -65,7 +71,9 @@ public static partial class PublicEndpoints
var archiveYearRows = await db.Results
.AsNoTracking()
.Where(result => result.Season.Year < season.Year)
.Where(result =>
result.Season.Year < season.Year
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
.GroupBy(result => result.Season.Year)
.Select(group => new
{
@@ -83,6 +91,33 @@ public static partial class PublicEndpoints
var publicCategories = season.Categories
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
.ToArray();
var featuredCategories = publicCategories
.GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var ordered = group
.OrderBy(category => category.SortOrder)
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
var first = ordered[0];
var maxNomineesPerUser = ordered
.Select(category => category.MaxNomineesPerUser)
.Where(value => value > 0)
.DefaultIfEmpty(3)
.Max();
return new FeaturedCategoryDto(
first.Id,
first.GroupName,
first.GroupName,
first.Description,
maxNomineesPerUser);
})
.OrderBy(category => publicCategories
.Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase))
.Min(item => item.SortOrder))
.ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var response = new OverviewResponse(
season.Id,
season.Year,
@@ -100,20 +135,15 @@ public static partial class PublicEndpoints
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
},
publicCategories
.Select(category => new FeaturedCategoryDto(
category.Id,
category.GroupName,
category.Name,
category.Description,
category.MaxNomineesPerUser))
.ToArray(),
featuredCategories,
winnerPreviewItems,
archiveYears,
new PublicSiteContentDto(
siteSettings.HostDisplayName,
siteSettings.HostTagline,
siteSettings.NewsletterUrl,
siteSettings.ShareXUrl,
siteSettings.ShareDiscordUrl,
siteSettings.PrivacyEmail,
siteSettings.PrivacyPolicyContent,
SeasonMappings.ReadSocialLinks(siteSettings),
@@ -124,11 +154,14 @@ public static partial class PublicEndpoints
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
? "Clip-Einreichungen sind aktuell geschlossen."
: siteSettings.ClipSubmissionDisabledMessage,
siteSettings.ShowactApplicationsEnabled,
showactApplicationsOpenNow,
siteSettings.ShowactApplicationStartsAt,
siteSettings.ShowactApplicationEndsAt,
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: siteSettings.ShowactApplicationDisabledMessage,
siteSettings.SponsorsVisible),
siteSettings.SponsorsVisible,
siteSettings.ShowactFormSchemaJson ?? "[]"),
SeasonMappings.ReadFaqItems(siteSettings));
return Results.Ok(response);
@@ -1,6 +1,7 @@
using Backend.Contracts;
using Backend.Data;
using Backend.Common;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
}
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
var publicCategories = season.Categories
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
.ToArray();
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
@@ -54,6 +57,8 @@ 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 =>
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
{
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
if (!usesDatabaseDemo)
{
return IsDemoLoginEnabled(configuration);
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
.Select(item => new
{
item.CategoryId,
item.CategoryGroupName,
item.Status,
Nominee = item.CandidateId != null
? item.Candidate!.DisplayName
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
var groupedNominations = nominations
.Where(item => item.Status != "rejected" && item.Status != "superseded")
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
.GroupBy(item => item.CategoryId)
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
.Select(group => new UserNominationStateDto(
group.Key,
group.Key.CategoryId,
group.Key.CategoryGroupName,
group.Select(item => item.Nominee!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()))