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,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);