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>
207 lines
7.4 KiB
C#
207 lines
7.4 KiB
C#
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('-');
|
|
}
|
|
}
|