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
@@ -0,0 +1,206 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Backend.Contracts;
using Backend.Domain;
namespace Backend.Services;
public sealed record SeasonSubcategoryTemplateSetting(
string Name,
string Slug,
int SortOrder,
int? ViewerRangeMin,
int? ViewerRangeMax);
public static class SeasonSubcategoryTemplateSettings
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly SeasonSubcategoryTemplateSetting[] DefaultTemplates =
[
new("Hidden Star", "hidden-star", 1, 1, 20),
new("Rising Star", "rising-star", 2, 21, 60),
new("Shining Star", "shining-star", 3, 61, null),
];
public static SeasonSubcategoryTemplateSetting[] Read(Season season, IEnumerable<Category>? fallbackCategories = null)
{
var stored = OnlyViewerTemplates(Parse(season.SubcategoryTemplatesJson));
if (stored.Length > 0)
{
return stored;
}
if (fallbackCategories is null)
{
return [];
}
var fallback = fallbackCategories
.GroupBy(category => new { category.Name, category.ViewerRangeMin, category.ViewerRangeMax })
.Where(group => group.Key.ViewerRangeMin is not null || group.Key.ViewerRangeMax is not null)
.Select(group =>
{
var first = group.OrderBy(item => item.SortOrder).First();
return Normalize(new SeasonSubcategoryTemplateSetting(
first.Name,
ExtractTemplateSlug(first.Slug, first.GroupName),
first.SortOrder,
first.ViewerRangeMin,
first.ViewerRangeMax));
})
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray();
return fallback.Length > 0 ? fallback : DefaultTemplates;
}
public static string Serialize(IEnumerable<SeasonSubcategoryTemplateSetting> templates) =>
JsonSerializer.Serialize(
templates.Select(Normalize)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase),
JsonOptions);
public static SeasonSubcategoryTemplateSetting[] Normalize(IEnumerable<AdminSubcategoryTemplateDto>? templates) =>
(templates ?? [])
.Select(template => Normalize(new SeasonSubcategoryTemplateSetting(
template.Name,
template.Slug,
template.SortOrder,
template.ViewerRangeMin,
template.ViewerRangeMax)))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray();
public static AdminSubcategoryTemplateDto[] ToDtos(IEnumerable<SeasonSubcategoryTemplateSetting> templates) =>
templates
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
.Select(item => new AdminSubcategoryTemplateDto(
item.Name,
item.Slug,
item.SortOrder,
item.ViewerRangeMin,
item.ViewerRangeMax))
.ToArray();
public static bool MatchesTemplate(Category category, IEnumerable<SeasonSubcategoryTemplateSetting> templates)
{
var categoryTemplateSlug = ExtractTemplateSlug(category.Slug, category.GroupName);
return templates.Any(template =>
string.Equals(category.Name, template.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(categoryTemplateSlug, template.Slug, StringComparison.OrdinalIgnoreCase)
|| category.Slug.EndsWith($"-{template.Slug}", StringComparison.OrdinalIgnoreCase));
}
private static SeasonSubcategoryTemplateSetting[] Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<SeasonSubcategoryTemplateSetting[]>(json, JsonOptions)?
.Select(Normalize)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray()
?? [];
}
catch (JsonException)
{
return [];
}
}
private static SeasonSubcategoryTemplateSetting Normalize(SeasonSubcategoryTemplateSetting template)
{
var name = template.Name.Trim();
var slug = Slugify(template.Slug);
if (string.IsNullOrWhiteSpace(slug))
{
slug = Slugify(name);
}
return template with
{
Name = name,
Slug = slug,
SortOrder = Math.Clamp(template.SortOrder, 1, 99),
ViewerRangeMin = NormalizeNullableNumber(template.ViewerRangeMin),
ViewerRangeMax = NormalizeNullableNumber(template.ViewerRangeMax),
};
}
private static int? NormalizeNullableNumber(int? value) => value is null ? null : Math.Clamp(value.Value, 0, 100000);
private static SeasonSubcategoryTemplateSetting[] OnlyViewerTemplates(IEnumerable<SeasonSubcategoryTemplateSetting> templates)
{
var items = templates
.Select(Normalize)
.Where(item => item.ViewerRangeMin is not null || item.ViewerRangeMax is not null)
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderBy(item => item.SortOrder).First())
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray();
return items.Length > 0 ? items : [];
}
private static string ExtractTemplateSlug(string categorySlug, string groupName)
{
var groupSlug = Slugify(groupName);
var slug = categorySlug.Trim().ToLowerInvariant();
var prefix = string.IsNullOrWhiteSpace(groupSlug) ? string.Empty : $"{groupSlug}-";
if (!string.IsNullOrWhiteSpace(prefix) && slug.StartsWith(prefix, StringComparison.Ordinal))
{
return slug[prefix.Length..];
}
return slug;
}
public static string Slugify(string? value)
{
var normalized = (value ?? string.Empty)
.Trim()
.ToLowerInvariant()
.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
var lastWasDash = false;
foreach (var character in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (char.IsLetterOrDigit(character))
{
builder.Append(character);
lastWasDash = false;
continue;
}
if (!lastWasDash && builder.Length > 0)
{
builder.Append('-');
lastWasDash = true;
}
}
return builder.ToString().Trim('-');
}
}