Files
vtuber-awards/Backend/Data/SeedAwardCatalogBootstrapper.cs
T
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

233 lines
9.3 KiB
C#

using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToListAsync();
var templates = SeedCatalog.DefaultSubcategoryTemplates
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray();
var usedCategories = new HashSet<Category>();
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
var nextSortOrder = 1;
foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder))
{
foreach (var template in templates)
{
var category = FindReusableCategory(categories, award, template, usedCategories)
?? new Category { SeasonId = season.Id };
usedCategories.Add(category);
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();
}
private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
{
if (staleCategories.Length == 0)
{
return;
}
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)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var existing = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.Select(item => new { item.CategoryId, item.DisplayName, item.ChannelSlug })
.ToArrayAsync();
var existingKeys = existing
.Select(item => $"{item.CategoryId}|{item.DisplayName}|{item.ChannelSlug}".ToLowerInvariant())
.ToHashSet();
foreach (var seed in seeds)
{
if (!categories.TryGetValue(seed.CategorySlug, out var category))
{
continue;
}
var key = $"{category.Id}|{seed.DisplayName}|{seed.ChannelSlug}".ToLowerInvariant();
if (existingKeys.Contains(key))
{
continue;
}
db.Candidates.Add(new Candidate
{
SeasonId = season.Id,
CategoryId = category.Id,
DisplayName = seed.DisplayName,
ChannelSlug = seed.ChannelSlug,
Platform = seed.Platform,
});
}
await db.SaveChangesAsync();
}
private static async Task EnsureWinnersAsync(AwardsDbContext db, Season season, WinnerSeed[] seeds)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var candidates = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
var existingResults = await db.Results
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
foreach (var result in existingResults)
{
if (categories.Values.FirstOrDefault(item => item.Id == result.CategoryId) is { } category
&& result.CategoryName != category.Name)
{
result.CategoryName = category.Name;
}
}
var existingResultCategoryIds = existingResults.Select(item => item.CategoryId).ToHashSet();
foreach (var seed in seeds)
{
if (!categories.TryGetValue(seed.CategorySlug, out var category) || existingResultCategoryIds.Contains(category.Id))
{
continue;
}
var candidate = candidates.FirstOrDefault(item =>
item.CategoryId == category.Id
&& string.Equals(item.DisplayName, seed.DisplayName, StringComparison.OrdinalIgnoreCase));
if (candidate is null)
{
continue;
}
db.Results.Add(new AwardResult
{
SeasonId = season.Id,
CategoryId = category.Id,
CandidateId = candidate.Id,
CategoryName = category.Name,
});
}
}
private static string BuildCategorySlug(string awardSlug, string templateSlug) =>
$"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}";
}