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 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 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 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 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 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(); 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 FindBlockedSubcategoryRemovalsAsync( AwardsDbContext db, List 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 categories, SeasonSubcategoryTemplateSetting template, string groupName, HashSet? 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 categories, SeasonSubcategoryTemplateSetting[] templates, string groupName) { var usedCategories = new HashSet(); 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}"; } }