b53c7fb736
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>
500 lines
20 KiB
C#
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}";
|
|
}
|
|
}
|