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
+123 -36
View File
@@ -1,4 +1,5 @@
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
@@ -7,54 +8,137 @@ public static partial class SeedDataBootstrapper
{
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
{
var seasonCategories = await db.Categories
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
.ToListAsync();
var templates = SeedCatalog.DefaultSubcategoryTemplates
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray();
var usedCategories = new HashSet<Category>();
foreach (var category in seasonCategories)
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
var nextSortOrder = 1;
foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder))
{
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
foreach (var template in templates)
{
continue;
}
var category = FindReusableCategory(categories, award, template, usedCategories)
?? new Category { SeasonId = season.Id };
usedCategories.Add(category);
var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug);
category.GroupName = target.GroupName;
category.Name = target.Name;
category.Slug = target.Slug;
category.Description = target.Description;
category.SortOrder = target.SortOrder;
category.MaxNomineesPerUser = 3;
category.GroupName = award.Name;
category.Name = template.Name;
category.Slug = BuildCategorySlug(award.Slug, template.Slug);
category.Description = award.Description;
category.SortOrder = nextSortOrder++;
category.MaxNomineesPerUser = 3;
category.ViewerRangeMin = template.ViewerRangeMin;
category.ViewerRangeMax = template.ViewerRangeMax;
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
{
db.Categories.Add(category);
categories.Add(category);
}
}
}
var staleCategories = categories
.Where(item => item.Id > 0 && !usedCategories.Contains(item))
.ToArray();
await RemoveStaleCategoryDataAsync(db, staleCategories);
await db.SaveChangesAsync();
}
var existing = await db.Categories
.Where(item => item.SeasonId == season.Id)
.Select(item => item.Slug)
.ToArrayAsync();
var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var seed in SeedCatalog.CategorySeeds)
private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
{
if (staleCategories.Length == 0)
{
if (existingSlugs.Contains(seed.Slug))
{
continue;
}
db.Categories.Add(new Category
{
SeasonId = season.Id,
GroupName = seed.GroupName,
Name = seed.Name,
Slug = seed.Slug,
Description = seed.Description,
SortOrder = seed.SortOrder,
MaxNomineesPerUser = 3,
});
return;
}
await db.SaveChangesAsync();
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
var staleCandidateIds = await db.Candidates
.Where(item => staleCategoryIds.Contains(item.CategoryId))
.Select(item => item.Id)
.ToArrayAsync();
var staleVoteEntries = await db.VoteEntries
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
.ToArrayAsync();
db.VoteEntries.RemoveRange(staleVoteEntries);
var staleResults = await db.Results
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
.ToArrayAsync();
db.Results.RemoveRange(staleResults);
var affectedClips = await db.ClipSubmissions
.Where(item =>
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
.ToArrayAsync();
foreach (var clip in affectedClips)
{
if (clip.CategoryId != null && staleCategoryIds.Contains(clip.CategoryId.Value))
{
clip.CategoryId = null;
}
if (clip.CandidateId != null && staleCandidateIds.Contains(clip.CandidateId.Value))
{
clip.CandidateId = null;
}
}
var affectedNominations = await db.Nominations
.Where(item =>
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value))
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
.ToArrayAsync();
foreach (var nomination in affectedNominations)
{
if (nomination.CategoryId != null && staleCategoryIds.Contains(nomination.CategoryId.Value))
{
nomination.CategoryId = null;
}
if (nomination.SuggestedCategoryId != null && staleCategoryIds.Contains(nomination.SuggestedCategoryId.Value))
{
nomination.SuggestedCategoryId = null;
}
if (nomination.CandidateId != null && staleCandidateIds.Contains(nomination.CandidateId.Value))
{
nomination.CandidateId = null;
}
}
var staleCandidates = await db.Candidates
.Where(item => staleCandidateIds.Contains(item.Id))
.ToArrayAsync();
db.Candidates.RemoveRange(staleCandidates);
db.Categories.RemoveRange(staleCategories);
}
private static Category? FindReusableCategory(
List<Category> categories,
AwardCategorySeed award,
SeasonSubcategoryTemplateSetting template,
HashSet<Category> usedCategories)
{
var targetSlug = BuildCategorySlug(award.Slug, template.Slug);
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.Slug, targetSlug, StringComparison.OrdinalIgnoreCase))
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.GroupName, award.Name, StringComparison.OrdinalIgnoreCase)
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase))
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.GroupName, award.LegacyGroupName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
}
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
@@ -142,4 +226,7 @@ public static partial class SeedDataBootstrapper
});
}
}
private static string BuildCategorySlug(string awardSlug, string templateSlug) =>
$"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}";
}