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>
226 lines
9.3 KiB
C#
226 lines
9.3 KiB
C#
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);
|