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
+1
View File
@@ -10,5 +10,6 @@ public interface IUserSessionService
Task<UserSession> CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default);
Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default);
Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default);
Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default);
}
+14
View File
@@ -0,0 +1,14 @@
namespace Backend.Services;
public interface IViewerStatsProvider
{
Task<ViewerStatsSnapshot?> GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken);
}
public sealed record ViewerStatsSnapshot(
int AverageViewers,
int HoursStreamed,
int HoursWatched,
int PeakViewers,
int FollowersGained,
string WindowKey);
@@ -0,0 +1,173 @@
using System.Text.RegularExpressions;
using Backend.Data;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Services;
public sealed partial class NominationEnrichmentService(
AwardsDbContext db,
IViewerStatsProvider viewerStatsProvider,
NominationTrackingReviewService trackingReviewService)
{
public async Task EnrichAsync(Nomination nomination, IReadOnlyCollection<Category> groupCategories, CancellationToken cancellationToken)
{
if (!TryResolveStreamIdentity(nomination.StreamUrl, out var identity))
{
nomination.TrackerStatus = "unresolved";
nomination.TrackerCheckedAt = DateTimeOffset.UtcNow;
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
return;
}
var streamerIdentity = await db.StreamerIdentities
.FirstOrDefaultAsync(item => item.NormalizedKey == identity.NormalizedKey, cancellationToken);
if (streamerIdentity is null)
{
streamerIdentity = new StreamerIdentity
{
Platform = identity.Platform,
Login = identity.Login,
NormalizedKey = identity.NormalizedKey,
DisplayName = identity.DisplayName,
ProfileUrl = identity.ProfileUrl,
};
db.StreamerIdentities.Add(streamerIdentity);
}
else
{
streamerIdentity.Platform = identity.Platform;
streamerIdentity.Login = identity.Login;
streamerIdentity.DisplayName = string.IsNullOrWhiteSpace(streamerIdentity.DisplayName)
? identity.DisplayName
: streamerIdentity.DisplayName;
streamerIdentity.ProfileUrl ??= identity.ProfileUrl;
}
streamerIdentity.LastResolvedAt = DateTimeOffset.UtcNow;
nomination.StreamerIdentity = streamerIdentity;
nomination.ResolvedChannel = identity.Login;
nomination.ResolvedPlatform = identity.Platform;
nomination.TrackerCheckedAt = DateTimeOffset.UtcNow;
if (!identity.SupportsViewerStats)
{
nomination.TrackerStatus = "unsupported_platform";
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
return;
}
var summary = await viewerStatsProvider.GetChannelSummaryAsync(identity.Login, cancellationToken);
nomination.AvgViewers = summary?.AverageViewers;
nomination.HoursStreamed = summary?.HoursStreamed;
nomination.HoursWatched = summary?.HoursWatched;
nomination.PeakViewers = summary?.PeakViewers;
nomination.FollowersGained = summary?.FollowersGained;
nomination.SuggestedCategoryId = nomination.AvgViewers.HasValue
? ResolveSuggestedCategoryId(groupCategories, nomination.AvgViewers.Value)
: null;
nomination.TrackerStatus = summary is not null ? "resolved" : "no_data";
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
}
public static bool TryResolveStreamIdentity(string? streamUrl, out ResolvedStreamerIdentity identity)
{
identity = default;
if (string.IsNullOrWhiteSpace(streamUrl) || !Uri.TryCreate(streamUrl.Trim(), UriKind.Absolute, out var uri))
{
return false;
}
var host = uri.Host.Replace("www.", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
var pathParts = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => part.TrimStart('@'))
.Where(part => !IgnoredPathParts.Contains(part, StringComparer.OrdinalIgnoreCase))
.ToArray();
var login = pathParts.FirstOrDefault() ?? string.Empty;
if (host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
{
login = pathParts.FirstOrDefault() ?? uri.Host;
}
var platform = ResolvePlatform(host);
login = SanitizeLogin(login);
if (string.IsNullOrWhiteSpace(login))
{
return false;
}
var profileUrl = BuildProfileUrl(platform, login, uri);
identity = new ResolvedStreamerIdentity(
platform,
login,
$"{platform.ToLowerInvariant()}:{login.ToLowerInvariant()}",
login,
profileUrl,
string.Equals(platform, "Twitch", StringComparison.OrdinalIgnoreCase));
return true;
}
private static int? ResolveSuggestedCategoryId(IEnumerable<Category> groupCategories, int averageViewers)
{
var orderedCategories = groupCategories
.OrderBy(item => item.SortOrder)
.ToArray();
var rangedCategories = orderedCategories
.Where(category => category.ViewerRangeMin.HasValue || category.ViewerRangeMax.HasValue)
.ToArray();
if (rangedCategories.Length > 0)
{
return rangedCategories
.FirstOrDefault(category =>
(!category.ViewerRangeMin.HasValue || averageViewers >= category.ViewerRangeMin.Value)
&& (!category.ViewerRangeMax.HasValue || averageViewers <= category.ViewerRangeMax.Value))
?.Id;
}
return orderedCategories
.FirstOrDefault()
?.Id;
}
private static string ResolvePlatform(string host)
{
if (host.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase)) return "Twitch";
if (host.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) || host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase)) return "YouTube";
if (host.Contains("kick.com", StringComparison.OrdinalIgnoreCase)) return "Kick";
var firstPart = host.Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
return string.IsNullOrWhiteSpace(firstPart)
? "Website"
: $"{char.ToUpperInvariant(firstPart[0])}{firstPart[1..]}";
}
private static string BuildProfileUrl(string platform, string login, Uri originalUrl) =>
platform.ToLowerInvariant() switch
{
"twitch" => $"https://twitch.tv/{login}",
"youtube" => $"https://youtube.com/{login}",
"kick" => $"https://kick.com/{login}",
_ => originalUrl.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped),
};
private static string SanitizeLogin(string value) =>
LoginRegex().Replace(value.Trim().TrimStart('@'), string.Empty);
private static readonly string[] IgnoredPathParts = ["c", "channel", "user", "live", "videos", "video", "clip", "clips", "directory"];
[GeneratedRegex("[^a-zA-Z0-9._-]", RegexOptions.Compiled)]
private static partial Regex LoginRegex();
}
public readonly record struct ResolvedStreamerIdentity(
string Platform,
string Login,
string NormalizedKey,
string DisplayName,
string ProfileUrl,
bool SupportsViewerStats);
@@ -0,0 +1,225 @@
using Backend.Data;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Services;
public sealed class NominationTrackingReviewService(AwardsDbContext db)
{
public async Task ReevaluateAsync(Nomination nomination, bool resetManualResolution, CancellationToken cancellationToken)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
ApplyEvaluation(nomination, TrackingRulesSettings.Read(settings), resetManualResolution);
}
public async Task ReevaluateAllAsync(bool resetManualResolution, CancellationToken cancellationToken)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
var rules = TrackingRulesSettings.Read(settings);
await ReevaluateAllAsync(rules, resetManualResolution, cancellationToken);
}
public async Task ReevaluateAllAsync(
TrackingRulesConfiguration rules,
bool resetManualResolution,
CancellationToken cancellationToken)
{
var nominations = await db.Nominations.ToArrayAsync(cancellationToken);
foreach (var nomination in nominations)
{
ApplyEvaluation(nomination, rules, resetManualResolution);
}
}
public NominationTrackingEvaluation Evaluate(Nomination nomination, TrackingRulesConfiguration rules)
{
var requiredMetrics = rules.ImportantMetrics
.Where(item => item.Enabled && item.RequiredForAutoClassification)
.ToArray();
var missingRequiredMetrics = requiredMetrics
.Where(metric => !IsMetricPresent(metric, nomination))
.ToArray();
var unsupportedAutomaticWindows = rules.ImportantMetrics
.Concat(rules.OptionalMetrics)
.Where(item => item.Enabled && item.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(item))
.ToArray();
var triggeredFlags = new List<TrackingFlagHit>();
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagTrackerUnresolved, nomination.TrackerStatus == "unresolved");
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedPlatform, nomination.TrackerStatus == "unsupported_platform");
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagNoTrackerData, nomination.TrackerStatus == "no_data");
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagMissingRequiredMetric, missingRequiredMetrics.Length > 0);
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedMetricWindow, unsupportedAutomaticWindows.Length > 0);
var needsManualReview = triggeredFlags.Any(flag => flag.RequiresManualReview);
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagManualReviewRequired, needsManualReview);
return new NominationTrackingEvaluation(
missingRequiredMetrics.Select(item => item.Key).ToArray(),
triggeredFlags.ToArray(),
needsManualReview || triggeredFlags.Any(flag => flag.Key == TrackingRulesSettings.FlagManualReviewRequired),
triggeredFlags.Any(flag => flag.BlocksApproval),
triggeredFlags.Any(flag => flag.AdminNoteRequiredOnOverride));
}
public TrackingMetricState[] BuildMetricStates(Nomination nomination, TrackingRulesConfiguration rules) =>
rules.ImportantMetrics
.Concat(rules.OptionalMetrics)
.Where(item => item.Enabled && item.ShowInReview)
.Select(metric => new TrackingMetricState(
metric.Key,
metric.Label,
metric.RequiredForAutoClassification,
metric.SourceSupport,
IsMetricPresent(metric, nomination),
ResolveMetricValue(metric, nomination),
metric.Description,
metric.WindowKey,
TrackingRulesSettings.WindowLabel(metric.WindowKey),
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
.ToArray();
private void ApplyEvaluation(Nomination nomination, TrackingRulesConfiguration rules, bool resetManualResolution)
{
var evaluation = Evaluate(nomination, rules);
nomination.TrackingFlagsJson = TrackingRulesSettings.SerializeFlagHits(evaluation.Flags);
if (resetManualResolution || nomination.TrackingReviewStatus is not ("reviewed" or "overridden"))
{
nomination.TrackingReviewStatus = evaluation.RequiresManualReview ? "flagged" : "clear";
if (resetManualResolution)
{
nomination.TrackingReviewNote = null;
nomination.TrackingReviewedByTwitchId = null;
nomination.TrackingReviewedAt = null;
}
}
}
private static bool IsMetricPresent(TrackingMetricRuleSetting metric, Nomination nomination)
{
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
{
return false;
}
return metric.Key switch
{
TrackingRulesSettings.AvgViewers => nomination.AvgViewers.HasValue,
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(nomination.TrackerStatus),
TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt.HasValue,
TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed.HasValue,
TrackingRulesSettings.HoursWatched => nomination.HoursWatched.HasValue,
TrackingRulesSettings.PeakViewers => nomination.PeakViewers.HasValue,
TrackingRulesSettings.FollowersGained => nomination.FollowersGained.HasValue,
_ => false,
};
}
private static string ResolveMetricValue(TrackingMetricRuleSetting metric, Nomination nomination)
{
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
{
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
}
return metric.Key switch
{
TrackingRulesSettings.AvgViewers => nomination.AvgViewers?.ToString() ?? "offen",
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(nomination.TrackerStatus) ? "offen" : nomination.TrackerStatus,
TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt?.ToString("g") ?? "offen",
TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed?.ToString() ?? "offen",
TrackingRulesSettings.HoursWatched => nomination.HoursWatched?.ToString() ?? "offen",
TrackingRulesSettings.PeakViewers => nomination.PeakViewers?.ToString() ?? "offen",
TrackingRulesSettings.FollowersGained => nomination.FollowersGained?.ToString() ?? "offen",
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
_ => "manuell",
};
}
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
{
var parts = new List<string>();
if (metric.TopCount.HasValue)
{
parts.Add($"Top {metric.TopCount.Value}");
}
if (metric.MinPrimaryCategorySharePercent.HasValue)
{
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
}
if (metric.MinPrimaryCategoryHours.HasValue)
{
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
}
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
{
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
}
if (metric.IgnoredCategories.Length > 0)
{
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
}
return parts.Count > 0
? string.Join(" · ", parts)
: "Top-Kategorien manuell pruefen";
}
private static void AddFlagIf(
ICollection<TrackingFlagHit> target,
IEnumerable<TrackingFlagRuleSetting> availableFlags,
string key,
bool shouldAdd)
{
if (!shouldAdd)
{
return;
}
var rule = availableFlags.FirstOrDefault(item => item.Key == key);
if (rule is null || !rule.Enabled || !rule.AutoTriggerEnabled)
{
return;
}
target.Add(new TrackingFlagHit(
rule.Key,
rule.Label,
rule.Severity,
rule.Description,
rule.RequiresManualReview,
rule.BlocksApproval,
rule.AdminNoteRequiredOnOverride));
}
}
public sealed record NominationTrackingEvaluation(
string[] MissingRequiredMetricKeys,
TrackingFlagHit[] Flags,
bool RequiresManualReview,
bool HasBlockingFlag,
bool RequiresOverrideNote);
public sealed record TrackingMetricState(
string Key,
string Label,
bool Required,
string SourceSupport,
bool Present,
string Value,
string Description,
string WindowKey,
string WindowLabel,
bool AutoWindowSupported);
@@ -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('-');
}
}
+346
View File
@@ -0,0 +1,346 @@
using System.Text.Json;
using Backend.Domain;
namespace Backend.Services;
public sealed record TrackingSourceSetting(
string ProviderKey,
string BaseUrl,
string NotesSummary,
bool ShowManualReviewNotesInReview);
public sealed record TrackingMetricRuleSetting(
string Key,
string Label,
bool Enabled,
string SourceSupport,
string Description,
bool RequiredForAutoClassification,
bool ShowInReview,
bool ShowInAdminSummary,
bool ManualOverrideAllowed,
string WindowKey,
string[] AutoSupportedWindowKeys,
string? ProviderFieldKey,
int? TopCount,
int? MinPrimaryCategorySharePercent,
int? MinPrimaryCategoryHours,
int? MaxDistinctCategoriesBeforeFlag,
string[] IgnoredCategories,
bool MatchAwardCategoryAgainstTopCategories,
bool FlagIfAwardCategoryNotInTopX,
bool FlagIfCategorySpreadTooWide,
bool FlagIfNoCategoryContextAvailable,
int? MinValue,
int? MaxValue);
public sealed record TrackingFlagRuleSetting(
string Key,
string Label,
bool Enabled,
string Severity,
string Description,
bool AutoTriggerEnabled,
bool RequiresManualReview,
bool BlocksApproval,
bool AdminNoteRequiredOnOverride);
public sealed record TrackingRulesConfiguration(
TrackingSourceSetting Source,
TrackingMetricRuleSetting[] ImportantMetrics,
TrackingMetricRuleSetting[] OptionalMetrics,
TrackingFlagRuleSetting[] Flags);
public sealed record TrackingFlagHit(
string Key,
string Label,
string Severity,
string Description,
bool RequiresManualReview,
bool BlocksApproval,
bool AdminNoteRequiredOnOverride);
public static class TrackingRulesSettings
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public const string ProviderKey = "twitchtracker";
public const string DefaultBaseUrl = "https://twitchtracker.com/api";
public const string Window7d = "7d";
public const string Window30d = "30d";
public const string Window90d = "90d";
public const string WindowAllTime = "all_time";
public const string AvgViewers = "avg_viewers";
public const string TrackerStatus = "tracker_status";
public const string TrackerCheckedAt = "tracker_checked_at";
public const string HoursStreamed = "hours_streamed";
public const string HoursWatched = "hours_watched";
public const string PeakViewers = "peak_viewers";
public const string FollowersGained = "followers_gained";
public const string CategoryFit = "category_fit";
public const string TopCategoriesContext = "top_categories_context";
public const string FlagTrackerUnresolved = "tracker_unresolved";
public const string FlagUnsupportedPlatform = "unsupported_platform";
public const string FlagNoTrackerData = "no_tracker_data";
public const string FlagMissingRequiredMetric = "missing_required_metric";
public const string FlagManualReviewRequired = "manual_review_required";
public const string FlagLowConfidenceSmallChannel = "low_confidence_small_channel";
public const string FlagInsufficientActivityContext = "insufficient_activity_context";
public const string FlagCategoryFitNeedsReview = "category_fit_needs_review";
public const string FlagUnsupportedMetricWindow = "unsupported_metric_window";
public static readonly string[] SupportedMetricWindows = [Window7d, Window30d, Window90d, WindowAllTime];
public static readonly string[] TwitchTrackerAutoWindowSupport = [Window30d];
public static TrackingSourceSetting DefaultSource { get; } =
new(
ProviderKey,
DefaultBaseUrl,
"TwitchTracker Basic API liefert aktuell Channel-Summary-Daten fuer 30 Tage. Andere Zeitfenster bleiben konfigurierbar, werden aber als manueller Review-Fall markiert.",
true);
public static TrackingMetricRuleSetting[] DefaultImportantMetrics { get; } =
[
new(AvgViewers, "Avg Viewer", true, "auto", "Durchschnittliche Viewer fuer den gewaehlten Zeitraum.", true, true, true, true, Window90d, TwitchTrackerAutoWindowSupport, "avg_viewers", null, null, null, null, [], false, false, false, false, null, null),
new(TrackerStatus, "Tracker-Status", true, "auto", "Zeigt, ob der TwitchTracker-Lookup sauber aufgeloest werden konnte.", true, true, true, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_status", null, null, null, null, [], false, false, false, false, null, null),
new(TrackerCheckedAt, "Letzter Tracker-Check", true, "auto", "Zeitpunkt der letzten automatischen Datenaufloesung.", true, true, false, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_checked_at", null, null, null, null, [], false, false, false, false, null, null),
];
public static TrackingMetricRuleSetting[] DefaultOptionalMetrics { get; } =
[
new(HoursStreamed, "Hours Streamed", true, "auto", "Gesamte Streamstunden im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_streamed", null, null, null, null, [], false, false, false, false, null, null),
new(HoursWatched, "Hours Watched", true, "auto", "Gesamte Watch Time fuer den gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_watched", null, null, null, null, [], false, false, false, false, null, null),
new(PeakViewers, "Peak Viewer", true, "auto", "Hoechster gleichzeitiger Zuschauerwert im Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "peak_viewers", null, null, null, null, [], false, false, false, false, null, null),
new(FollowersGained, "Follower Growth", true, "auto", "Follower-Zuwachs im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "followers_gained", null, null, null, null, [], false, false, false, false, null, null),
new(CategoryFit, "Category Fit", false, "context_only", "Admin-Einschaetzung, ob die Person inhaltlich zur Unterkategorie passt.", false, true, false, true, Window90d, [], null, null, null, null, null, [], false, false, false, false, null, null),
new(TopCategoriesContext, "Top Categories Context", false, "context_only", "Manueller Kontext aus zuletzt meistgestreamten Kategorien oder Games des Channels.", false, true, true, true, Window90d, [], null, 5, 60, 20, 6, ["Just Chatting", "Special Events"], true, true, true, true, null, null),
];
public static TrackingFlagRuleSetting[] DefaultFlags { get; } =
[
new(FlagTrackerUnresolved, "Tracker-Link nicht aufloesbar", true, "high", "Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.", true, true, false, false),
new(FlagUnsupportedPlatform, "Plattform nicht unterstuetzt", true, "medium", "Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.", true, true, false, false),
new(FlagNoTrackerData, "Keine Tracker-Daten", true, "medium", "TwitchTracker hat keinen belastbaren Summary-Wert geliefert.", true, true, false, false),
new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, true, false),
new(FlagManualReviewRequired, "Manuelle Pruefung noetig", true, "medium", "Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.", true, true, false, false),
new(FlagLowConfidenceSmallChannel, "Low Confidence Small Channel", false, "low", "Kleine Kanaele koennen manuell tiefer geprueft werden.", false, true, false, false),
new(FlagInsufficientActivityContext, "Zu wenig Aktivitaetskontext", false, "low", "Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.", false, true, false, false),
new(FlagCategoryFitNeedsReview, "Category Fit manuell pruefen", false, "low", "Unterkategorie muss inhaltlich manuell bestaetigt werden.", false, true, false, false),
new(FlagUnsupportedMetricWindow, "Gewaehltes Zeitfenster nicht auto-verfuegbar", true, "medium", "Die aktuelle TwitchTracker API liefert diese Metrik nicht fuer das konfigurierte Zeitfenster.", true, true, false, false),
];
public static TrackingRulesConfiguration Read(SiteSettings? settings)
{
var parsed = Parse(settings?.TrackingRulesJson);
var source = NormalizeSource(parsed?.Source, DefaultSource);
return new TrackingRulesConfiguration(
source,
MergeMetrics(parsed?.ImportantMetrics, DefaultImportantMetrics),
MergeMetrics(parsed?.OptionalMetrics, DefaultOptionalMetrics),
MergeFlags(parsed?.Flags, DefaultFlags));
}
public static string Serialize(TrackingRulesConfiguration configuration)
{
var normalized = new TrackingRulesConfiguration(
NormalizeSource(configuration.Source, DefaultSource),
MergeMetrics(configuration.ImportantMetrics, DefaultImportantMetrics),
MergeMetrics(configuration.OptionalMetrics, DefaultOptionalMetrics),
MergeFlags(configuration.Flags, DefaultFlags));
return JsonSerializer.Serialize(normalized, JsonOptions);
}
public static TrackingMetricRuleSetting FindMetric(IEnumerable<TrackingMetricRuleSetting> rules, string key) =>
rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
?? DefaultImportantMetrics.Concat(DefaultOptionalMetrics).First(item => item.Key == key);
public static TrackingFlagRuleSetting FindFlag(IEnumerable<TrackingFlagRuleSetting> flags, string key) =>
flags.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
?? DefaultFlags.First(item => item.Key == key);
public static string NormalizeBaseUrl(string? rawValue)
{
var trimmed = (rawValue ?? string.Empty).Trim();
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return DefaultBaseUrl;
}
return uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
}
public static string NormalizeWindowKey(string? value, string fallback)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return SupportedMetricWindows.Contains(normalized, StringComparer.OrdinalIgnoreCase)
? normalized
: fallback;
}
public static string WindowLabel(string windowKey) =>
NormalizeWindowKey(windowKey, Window30d) switch
{
Window7d => "7 Tage",
Window30d => "30 Tage",
Window90d => "3 Monate",
WindowAllTime => "All Time",
_ => "30 Tage",
};
public static bool SupportsAutomaticWindow(TrackingMetricRuleSetting metric) =>
metric.ProviderFieldKey is not null
&& metric.AutoSupportedWindowKeys.Any(item => string.Equals(item, metric.WindowKey, StringComparison.OrdinalIgnoreCase));
public static TrackingFlagHit[] ReadFlagHits(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
public static string SerializeFlagHits(IEnumerable<TrackingFlagHit> flags) =>
JsonSerializer.Serialize(flags, JsonOptions);
private static TrackingRulesConfigurationDto? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<TrackingRulesConfigurationDto>(json, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
private static TrackingMetricRuleSetting[] MergeMetrics(
IEnumerable<TrackingMetricRuleSetting>? storedRules,
IEnumerable<TrackingMetricRuleSetting> defaults) =>
defaults
.Select(defaultRule =>
{
var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
return NormalizeMetric(storedRule, defaultRule);
})
.ToArray();
private static TrackingFlagRuleSetting[] MergeFlags(
IEnumerable<TrackingFlagRuleSetting>? storedRules,
IEnumerable<TrackingFlagRuleSetting> defaults) =>
defaults
.Select(defaultRule =>
{
var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
return NormalizeFlag(storedRule, defaultRule);
})
.ToArray();
private static TrackingSourceSetting NormalizeSource(TrackingSourceSetting? stored, TrackingSourceSetting fallback) =>
new(
fallback.ProviderKey,
NormalizeBaseUrl(stored?.BaseUrl ?? fallback.BaseUrl),
string.IsNullOrWhiteSpace(stored?.NotesSummary) ? fallback.NotesSummary : stored.NotesSummary.Trim(),
stored?.ShowManualReviewNotesInReview ?? fallback.ShowManualReviewNotesInReview);
private static TrackingMetricRuleSetting NormalizeMetric(TrackingMetricRuleSetting? stored, TrackingMetricRuleSetting fallback)
{
if (stored is null)
{
return fallback;
}
var sourceSupport = stored.SourceSupport.Trim().ToLowerInvariant() switch
{
"auto" => "auto",
"manual" => "manual",
"context_only" => "context_only",
_ => fallback.SourceSupport,
};
var autoSupportedWindowKeys = (stored.AutoSupportedWindowKeys ?? fallback.AutoSupportedWindowKeys)
.Select(item => NormalizeWindowKey(item, Window30d))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return fallback with
{
Enabled = stored.Enabled,
SourceSupport = sourceSupport,
RequiredForAutoClassification = stored.RequiredForAutoClassification,
ShowInReview = stored.ShowInReview,
ShowInAdminSummary = stored.ShowInAdminSummary,
ManualOverrideAllowed = stored.ManualOverrideAllowed,
WindowKey = NormalizeWindowKey(stored.WindowKey, fallback.WindowKey),
AutoSupportedWindowKeys = autoSupportedWindowKeys,
ProviderFieldKey = string.IsNullOrWhiteSpace(stored.ProviderFieldKey) ? fallback.ProviderFieldKey : stored.ProviderFieldKey.Trim(),
TopCount = stored.TopCount,
MinPrimaryCategorySharePercent = stored.MinPrimaryCategorySharePercent,
MinPrimaryCategoryHours = stored.MinPrimaryCategoryHours,
MaxDistinctCategoriesBeforeFlag = stored.MaxDistinctCategoriesBeforeFlag,
IgnoredCategories = (stored.IgnoredCategories ?? fallback.IgnoredCategories)
.Select(item => item.Trim())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(),
MatchAwardCategoryAgainstTopCategories = stored.MatchAwardCategoryAgainstTopCategories,
FlagIfAwardCategoryNotInTopX = stored.FlagIfAwardCategoryNotInTopX,
FlagIfCategorySpreadTooWide = stored.FlagIfCategorySpreadTooWide,
FlagIfNoCategoryContextAvailable = stored.FlagIfNoCategoryContextAvailable,
MinValue = stored.MinValue,
MaxValue = stored.MaxValue,
};
}
private static TrackingFlagRuleSetting NormalizeFlag(TrackingFlagRuleSetting? stored, TrackingFlagRuleSetting fallback)
{
if (stored is null)
{
return fallback;
}
var severity = stored.Severity.Trim().ToLowerInvariant() switch
{
"high" => "high",
"medium" => "medium",
"low" => "low",
_ => fallback.Severity,
};
return fallback with
{
Enabled = stored.Enabled,
Severity = severity,
AutoTriggerEnabled = stored.AutoTriggerEnabled,
RequiresManualReview = stored.RequiresManualReview,
BlocksApproval = stored.BlocksApproval,
AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride,
};
}
private sealed record TrackingRulesConfigurationDto(
TrackingSourceSetting? Source,
TrackingMetricRuleSetting[]? ImportantMetrics,
TrackingMetricRuleSetting[]? OptionalMetrics,
TrackingFlagRuleSetting[]? Flags);
}
@@ -0,0 +1,85 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Services;
public sealed class TwitchTrackerViewerStatsProvider(
IHttpClientFactory httpClientFactory,
AwardsDbContext db,
ILogger<TwitchTrackerViewerStatsProvider> logger)
: IViewerStatsProvider
{
public async Task<ViewerStatsSnapshot?> GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken)
{
var login = twitchLogin.Trim().TrimStart('@').ToLowerInvariant();
if (string.IsNullOrWhiteSpace(login))
{
return null;
}
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(3));
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, timeout.Token);
var baseUrl = TrackingRulesSettings.NormalizeBaseUrl(settings?.ViewerStatsProviderBaseUrl);
var client = httpClientFactory.CreateClient("TwitchTracker");
var response = await client.GetFromJsonAsync<TwitchTrackerChannelSummaryResponse>(
$"{baseUrl}/channels/summary/{Uri.EscapeDataString(login)}",
timeout.Token);
if (response is null)
{
return null;
}
return new ViewerStatsSnapshot(
response.AverageViewers,
Math.Max(0, response.MinutesStreamed / 60),
response.HoursWatched,
response.PeakViewers,
response.FollowersGained,
TrackingRulesSettings.Window30d);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogInformation("TwitchTracker API lookup timed out for {TwitchLogin}.", login);
return null;
}
catch (Exception ex)
{
logger.LogInformation(ex, "TwitchTracker API lookup failed for {TwitchLogin}.", login);
return null;
}
}
private sealed class TwitchTrackerChannelSummaryResponse
{
[JsonPropertyName("rank")]
public int Rank { get; init; }
[JsonPropertyName("minutes_streamed")]
public int MinutesStreamed { get; init; }
[JsonPropertyName("avg_viewers")]
public int AverageViewers { get; init; }
[JsonPropertyName("max_viewers")]
public int PeakViewers { get; init; }
[JsonPropertyName("hours_watched")]
public int HoursWatched { get; init; }
[JsonPropertyName("followers")]
public int FollowersGained { get; init; }
[JsonPropertyName("followers_total")]
public int FollowersTotal { get; init; }
}
}
+29 -5
View File
@@ -1,15 +1,18 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Repositories;
using Backend.Security;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
namespace Backend.Services;
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
public sealed class UserSessionService(IUserSessionRepository userSessionRepository, AwardsDbContext db) : IUserSessionService
{
private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12);
public const int MinimumIdleTimeoutHours = 3;
public const int DefaultIdleTimeoutHours = 3;
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
@@ -27,7 +30,8 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
}
var now = DateTimeOffset.UtcNow;
if (IsExpired(session, now))
var idleSessionLifetime = await ResolveIdleSessionLifetimeAsync(cancellationToken);
if (IsExpired(session, now, idleSessionLifetime))
{
session.IsActive = false;
await userSessionRepository.SaveChangesAsync(cancellationToken);
@@ -40,6 +44,17 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
return session;
}
public async Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default)
{
var configuredHours = await db.SiteSettings
.AsNoTracking()
.Where(item => item.Id == 1)
.Select(item => (int?)item.SessionIdleTimeoutHours)
.FirstOrDefaultAsync(cancellationToken);
return NormalizeIdleTimeoutHours(configuredHours);
}
public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
{
return CreateSessionAsync(
@@ -94,9 +109,18 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
: null;
}
private static bool IsExpired(UserSession session, DateTimeOffset now) =>
private async Task<TimeSpan> ResolveIdleSessionLifetimeAsync(CancellationToken cancellationToken)
{
var idleTimeoutHours = await GetIdleTimeoutHoursAsync(cancellationToken);
return TimeSpan.FromHours(idleTimeoutHours);
}
public static int NormalizeIdleTimeoutHours(int? configuredHours) =>
Math.Max(MinimumIdleTimeoutHours, configuredHours ?? DefaultIdleTimeoutHours);
private static bool IsExpired(UserSession session, DateTimeOffset now, TimeSpan idleSessionLifetime) =>
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime);
|| session.LastSeenAt <= now.Subtract(idleSessionLifetime);
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
{
+23
View File
@@ -19,6 +19,7 @@ public static class WorkflowRuleSettings
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; } =
[
@@ -26,11 +27,28 @@ public static class WorkflowRuleSettings
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 =>
{
@@ -98,6 +116,11 @@ public static class WorkflowRuleSettings
_ => fallback.Mode,
};
if (string.Equals(fallback.Key, RecommendedNominatorsPerSubcategory, StringComparison.OrdinalIgnoreCase))
{
mode = "warn";
}
return rule with
{
Key = fallback.Key,