Files
vtuber-awards/Backend/Services/WorkflowRuleSettings.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

135 lines
5.1 KiB
C#

using System.Text.Json;
using Backend.Domain;
namespace Backend.Services;
public sealed record WorkflowRuleSetting(
string Key,
string Label,
bool Enabled,
int Limit,
string Mode,
string Description);
public static class WorkflowRuleSettings
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public const string MaxFinalistsPerCategory = "max_finalists_per_category";
public const string MaxCandidateAppearances = "max_candidate_appearances";
public const string MaxWinnerPlacements = "max_winner_placements";
public const string WinnerRequiresClip = "winner_requires_clip";
public const string RecommendedNominatorsPerSubcategory = "recommended_nominators_per_subcategory";
public static WorkflowRuleSetting[] Defaults { get; } =
[
new(MaxFinalistsPerCategory, "Finale Kandidat:innen pro Kategorie", true, 4, "block", "Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden."),
new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."),
new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."),
new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."),
new(RecommendedNominatorsPerSubcategory, "Empfohlene Nominierer pro Unterkategorie", true, 4, "warn", "Zeigt in der Kategorie-Übersicht an, ab wann eine Unterkategorie nominierungsseitig gut getragen ist. Diese Regel blockiert nichts."),
];
public static WorkflowRuleSetting[] Read(SiteSettings? settings)
{
var storedRules = Parse(settings?.WorkflowRulesJson);
return Read(storedRules);
}
public static WorkflowRuleSetting[] Read(Season? season, SiteSettings? settings)
{
var seasonRules = Parse(season?.WorkflowRulesJson);
if (seasonRules.Length > 0)
{
return Read(seasonRules);
}
return Read(settings);
}
private static WorkflowRuleSetting[] Read(WorkflowRuleSetting[] storedRules)
{
return Defaults
.Select(defaultRule =>
{
var storedRule = storedRules.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
return storedRule is null ? defaultRule : Normalize(storedRule, defaultRule);
})
.ToArray();
}
public static string Serialize(IEnumerable<WorkflowRuleSetting> rules) =>
JsonSerializer.Serialize(rules.Select(rule => Normalize(rule, Defaults.FirstOrDefault(item => item.Key == rule.Key) ?? rule)), JsonOptions);
public static WorkflowRuleSetting Find(IEnumerable<WorkflowRuleSetting> rules, string key) =>
rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
?? Defaults.First(item => item.Key == key);
public static bool ShouldBlock(WorkflowRuleSetting rule) =>
rule.Enabled && string.Equals(rule.Mode, "block", StringComparison.OrdinalIgnoreCase);
public static string CandidateIdentityKey(Candidate candidate)
{
var channel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(channel))
{
return $"slug:{channel}";
}
return $"name:{candidate.DisplayName.Trim().ToLowerInvariant()}";
}
public static string CandidateIdentityKey(string displayName, string channelSlug)
{
var channel = channelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(channel))
{
return $"slug:{channel}";
}
return $"name:{displayName.Trim().ToLowerInvariant()}";
}
private static WorkflowRuleSetting[] Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<WorkflowRuleSetting[]>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static WorkflowRuleSetting Normalize(WorkflowRuleSetting rule, WorkflowRuleSetting fallback)
{
var mode = rule.Mode.Trim().ToLowerInvariant() switch
{
"block" => "block",
"warn" => "warn",
_ => fallback.Mode,
};
if (string.Equals(fallback.Key, RecommendedNominatorsPerSubcategory, StringComparison.OrdinalIgnoreCase))
{
mode = "warn";
}
return rule with
{
Key = fallback.Key,
Label = string.IsNullOrWhiteSpace(rule.Label) ? fallback.Label : rule.Label.Trim(),
Enabled = rule.Enabled,
Limit = Math.Clamp(rule.Limit, 1, 50),
Mode = mode,
Description = string.IsNullOrWhiteSpace(rule.Description) ? fallback.Description : rule.Description.Trim(),
};
}
}