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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user