Files
vtuber-awards/Backend/Endpoints/AdminSeasonCategoryGroupEndpoints.cs
AzuTear b53c7fb736 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>
2026-06-28 23:32:21 +02:00

500 lines
20 KiB
C#

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}";
}
}