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:
@@ -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('-');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user