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,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);
|
||||
Reference in New Issue
Block a user