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,36 @@
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Common;
|
||||
|
||||
public static class ShowactApplicationSchedule
|
||||
{
|
||||
public static string? Validate(DateOnly? startsAt, DateOnly? endsAt)
|
||||
{
|
||||
if (startsAt.HasValue && endsAt.HasValue && startsAt.Value > endsAt.Value)
|
||||
{
|
||||
return "Der Showact-Zeitraum ist ungueltig. Der Start darf nicht nach der Deadline liegen.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsOpenNow(SiteSettings settings, DateOnly today)
|
||||
{
|
||||
if (!settings.ShowactApplicationsEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settings.ShowactApplicationStartsAt.HasValue && today < settings.ShowactApplicationStartsAt.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settings.ShowactApplicationEndsAt.HasValue && today > settings.ShowactApplicationEndsAt.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,27 @@ public sealed record AdminAuditEntriesResponse(
|
||||
|
||||
public sealed record AdminNominationReviewItemDto(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string CategoryGroupName,
|
||||
string CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
bool RequiresManualReview,
|
||||
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
@@ -68,6 +84,32 @@ public sealed record AdminNominationReviewItemDto(
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
public sealed record AdminNominationReviewGroupDto(
|
||||
int Id,
|
||||
int[] NominationIds,
|
||||
string CategoryGroupName,
|
||||
string DisplayName,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
bool RequiresManualReview,
|
||||
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
int NominationTally,
|
||||
int UniqueSubmitterCount,
|
||||
DateTimeOffset FirstSubmittedAt,
|
||||
DateTimeOffset LastSubmittedAt);
|
||||
|
||||
public sealed record AdminClipSubmissionItemDto(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
@@ -87,10 +129,17 @@ public sealed record ApproveNominationRequest(
|
||||
string? DisplayName,
|
||||
string? ChannelSlug,
|
||||
string? Platform,
|
||||
int? CategoryId,
|
||||
string? ReviewNote);
|
||||
|
||||
public sealed record RejectNominationRequest(string? ReviewNote);
|
||||
|
||||
public sealed record ReopenRejectedNominationRequest(string? ReviewNote);
|
||||
|
||||
public sealed record UpdateNominationTrackingReviewRequest(
|
||||
string Status,
|
||||
string? ReviewNote);
|
||||
|
||||
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
|
||||
|
||||
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
|
||||
@@ -136,3 +185,24 @@ public sealed record AdminWorkflowRuleDto(
|
||||
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
|
||||
|
||||
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
|
||||
|
||||
public sealed record AdminTrackingFlagHitDto(
|
||||
string Key,
|
||||
string Label,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record AdminTrackingMetricStateDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Required,
|
||||
string SourceSupport,
|
||||
bool Present,
|
||||
string Value,
|
||||
string Description,
|
||||
string WindowKey,
|
||||
string WindowLabel,
|
||||
bool AutoWindowSupported);
|
||||
|
||||
@@ -16,14 +16,25 @@ public sealed record AdminCategoryItemDto(
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax,
|
||||
int CandidateCount);
|
||||
|
||||
public sealed record AdminSubcategoryTemplateDto(
|
||||
string Name,
|
||||
string Slug,
|
||||
int SortOrder,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public sealed record AdminCandidateItemDto(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string Platform,
|
||||
int NominationTally,
|
||||
string AcceptanceStatus,
|
||||
string? AcceptanceNote,
|
||||
string? ClipCompilationUrl,
|
||||
@@ -36,6 +47,7 @@ public sealed record AdminAwardResultItemDto(
|
||||
int CategoryId,
|
||||
string CategoryName,
|
||||
int CandidateId,
|
||||
int? StreamerIdentityId,
|
||||
string CandidateDisplayName,
|
||||
string CandidateChannelSlug,
|
||||
string CandidatePlatform);
|
||||
@@ -56,10 +68,14 @@ public sealed record AdminSeasonDetailResponse(
|
||||
DateOnly ReviewEndsAt,
|
||||
DateOnly ShowDate,
|
||||
TimeOnly ShowStartsAt,
|
||||
IEnumerable<AdminSubcategoryTemplateDto> SubcategoryTemplates,
|
||||
IEnumerable<AdminCategoryItemDto> Categories,
|
||||
IEnumerable<AdminCandidateItemDto> Candidates,
|
||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
||||
IEnumerable<AdminNominationReviewGroupDto> PendingNominationGroups,
|
||||
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
||||
string TrackingReviewNotes,
|
||||
bool ShowTrackingReviewNotes,
|
||||
IEnumerable<AdminAwardResultItemDto> Results,
|
||||
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
||||
|
||||
@@ -102,6 +118,17 @@ public sealed record UpsertCategoryRequest(
|
||||
string Slug,
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public sealed record UpdateSeasonSubcategoryTemplatesRequest(
|
||||
AdminSubcategoryTemplateDto[] Templates);
|
||||
|
||||
public sealed record UpsertCategoryGroupRequest(
|
||||
string GroupName,
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser);
|
||||
|
||||
public sealed record UpsertCandidateRequest(
|
||||
|
||||
@@ -4,6 +4,8 @@ public sealed record AdminSiteSettingsResponse(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
string? PrivacyPolicyUpdatedBy,
|
||||
@@ -17,12 +19,15 @@ public sealed record AdminSiteSettingsResponse(
|
||||
string ShowactsUrl,
|
||||
string ShowactsContent,
|
||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||
IEnumerable<FaqItemDto> Faq);
|
||||
IEnumerable<FaqItemDto> Faq,
|
||||
string ShowactFormSchemaJson);
|
||||
|
||||
public sealed record UpdateSiteSettingsRequest(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
string ImprintUrl,
|
||||
@@ -34,7 +39,8 @@ public sealed record UpdateSiteSettingsRequest(
|
||||
string ShowactsUrl,
|
||||
string ShowactsContent,
|
||||
PublicSocialLinkDto[] SocialLinks,
|
||||
FaqItemDto[] Faq);
|
||||
FaqItemDto[] Faq,
|
||||
string? ShowactFormSchemaJson = null);
|
||||
|
||||
public sealed record AdminOperationalSettingsResponse(
|
||||
bool DemoLoginManagedByDatabase,
|
||||
@@ -49,6 +55,7 @@ public sealed record AdminOperationalSettingsResponse(
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
@@ -59,6 +66,9 @@ public sealed record AdminOptionalFeatureSettingsResponse(
|
||||
bool ClipAdminMenuVisible,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
bool ShowactApplicationsOpenNow,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
|
||||
@@ -68,6 +78,8 @@ public sealed record UpdateOptionalFeatureSettingsRequest(
|
||||
bool ClipAdminMenuVisible,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
|
||||
@@ -81,6 +93,71 @@ public sealed record UpdateOperationalSettingsRequest(
|
||||
string? TwitchClientSecret,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
|
||||
public sealed record AdminTrackingSourceDto(
|
||||
string ProviderKey,
|
||||
string ProviderLabel,
|
||||
string BaseUrl,
|
||||
string NotesSummary,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
public sealed record AdminTrackingMetricRuleDto(
|
||||
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 AdminTrackingFlagRuleDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool AutoTriggerEnabled,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record AdminTrackingRulesResponse(
|
||||
AdminTrackingSourceDto Source,
|
||||
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||
AdminTrackingFlagRuleDto[] Flags,
|
||||
string ManualReviewNotes);
|
||||
|
||||
public sealed record UpdateTrackingRulesRequest(
|
||||
AdminTrackingSourceDto Source,
|
||||
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||
AdminTrackingFlagRuleDto[] Flags,
|
||||
string ManualReviewNotes);
|
||||
|
||||
public sealed record UpdateTrackingSourceRequest(
|
||||
AdminTrackingSourceDto Source);
|
||||
|
||||
public sealed record UpdateTrackingReviewNotesRequest(
|
||||
string ManualReviewNotes,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed record AuthSessionDto(
|
||||
string DisplayName,
|
||||
string Role,
|
||||
IEnumerable<string> PermissionKeys,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MustChangePassword = false,
|
||||
string? TeamLogin = null,
|
||||
string? BoundTwitchUserId = null,
|
||||
|
||||
@@ -36,16 +36,18 @@ public sealed record ShowactApplicationDto(
|
||||
string Status,
|
||||
string? ReviewNote,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
DateTimeOffset? ReviewedAt,
|
||||
string FieldResponsesJson);
|
||||
|
||||
public sealed record CreateShowactApplicationRequest(
|
||||
string ArtistName,
|
||||
string ContactEmail,
|
||||
string ContactDiscord,
|
||||
string PlatformUrl,
|
||||
string PerformanceType,
|
||||
string Description,
|
||||
string TechnicalNotes,
|
||||
string ReferenceUrl);
|
||||
string? ArtistName = null,
|
||||
string? ContactEmail = null,
|
||||
string? ContactDiscord = null,
|
||||
string? PlatformUrl = null,
|
||||
string? PerformanceType = null,
|
||||
string? Description = null,
|
||||
string? TechnicalNotes = null,
|
||||
string? ReferenceUrl = null,
|
||||
string? FieldResponsesJson = null);
|
||||
|
||||
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
|
||||
|
||||
@@ -50,6 +50,8 @@ public sealed record PublicSiteContentDto(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||
@@ -66,8 +68,11 @@ public sealed record PublicFeatureFlagsDto(
|
||||
bool ClipReviewEnabled,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
bool SponsorsVisible,
|
||||
string ShowactFormSchemaJson);
|
||||
|
||||
public sealed record OverviewResponse(
|
||||
int SeasonId,
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed record PublicCategoryDetailDto(
|
||||
string GroupName,
|
||||
string Description,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax,
|
||||
IEnumerable<CandidateSummaryDto> Candidates);
|
||||
|
||||
public sealed record SeasonCategoriesResponse(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Backend.Contracts;
|
||||
|
||||
public sealed record UserNominationStateDto(
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string CategoryGroupName,
|
||||
string[] Nominees);
|
||||
|
||||
public sealed record UserVoteStateDto(
|
||||
|
||||
@@ -6,7 +6,8 @@ public sealed record NominationEntryRequest(
|
||||
|
||||
public sealed record CreateNominationRequest(
|
||||
int Year,
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string TwitchUserId,
|
||||
string[]? Nominees,
|
||||
NominationEntryRequest[]? Nominations);
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
public DbSet<Season> Seasons => Set<Season>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<Candidate> Candidates => Set<Candidate>();
|
||||
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
|
||||
public DbSet<AwardResult> Results => Set<AwardResult>();
|
||||
public DbSet<Nomination> Nominations => Set<Nomination>();
|
||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||
@@ -30,6 +31,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.Name).HasMaxLength(160);
|
||||
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
|
||||
entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SiteSettings>(entity =>
|
||||
@@ -53,14 +56,19 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
||||
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
||||
entity.Property(item => item.TwitchScope).HasMaxLength(300);
|
||||
entity.Property(item => item.SessionIdleTimeoutHours).HasDefaultValue(3);
|
||||
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
||||
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
||||
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.TrackingRulesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.ViewerStatsProviderBaseUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
|
||||
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
|
||||
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
|
||||
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
|
||||
entity.Property(item => item.ShowactApplicationStartsAt);
|
||||
entity.Property(item => item.ShowactApplicationEndsAt);
|
||||
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
|
||||
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
|
||||
});
|
||||
@@ -93,13 +101,17 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.GroupName).HasMaxLength(80);
|
||||
entity.Property(item => item.Name).HasMaxLength(120);
|
||||
entity.Property(item => item.Description).HasMaxLength(400);
|
||||
entity.Property(item => item.ViewerRangeMin);
|
||||
entity.Property(item => item.ViewerRangeMax);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Candidate>(entity =>
|
||||
{
|
||||
entity.HasIndex(item => item.StreamerIdentityId);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||
entity.Property(item => item.NominationTally).HasDefaultValue(0);
|
||||
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
|
||||
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
|
||||
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
|
||||
@@ -108,15 +120,47 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<StreamerIdentity>(entity =>
|
||||
{
|
||||
entity.HasIndex(item => item.NormalizedKey).IsUnique();
|
||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||
entity.Property(item => item.Login).HasMaxLength(120);
|
||||
entity.Property(item => item.NormalizedKey).HasMaxLength(180);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.ProfileUrl).HasMaxLength(500);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Nomination>(entity =>
|
||||
{
|
||||
entity.Property(item => item.CategoryGroupName).HasMaxLength(80).HasDefaultValue(string.Empty);
|
||||
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.CandidateText).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamUrl).HasMaxLength(300);
|
||||
entity.Property(item => item.ResolvedChannel).HasMaxLength(120);
|
||||
entity.Property(item => item.ResolvedPlatform).HasMaxLength(40);
|
||||
entity.Property(item => item.HoursStreamed);
|
||||
entity.Property(item => item.HoursWatched);
|
||||
entity.Property(item => item.PeakViewers);
|
||||
entity.Property(item => item.FollowersGained);
|
||||
entity.Property(item => item.TrackerStatus).HasMaxLength(40).HasDefaultValue("pending");
|
||||
entity.Property(item => item.TrackingReviewStatus).HasMaxLength(30).HasDefaultValue("clear");
|
||||
entity.Property(item => item.TrackingFlagsJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.TrackingReviewNote).HasMaxLength(1000);
|
||||
entity.Property(item => item.TrackingReviewedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.Status).HasMaxLength(20);
|
||||
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||
entity.HasIndex(item => new { item.SeasonId, item.CategoryGroupName, item.Status });
|
||||
entity.HasIndex(item => new { item.SeasonId, item.StreamerIdentityId, item.CategoryGroupName });
|
||||
entity.HasOne(item => item.Category)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.CategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(item => item.SuggestedCategory)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.SuggestedCategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<VoteBallot>(entity =>
|
||||
|
||||
@@ -19,6 +19,15 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingRulesJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ViewerStatsProviderBaseUrl" character varying(400) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewNotes" text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
@@ -34,12 +43,30 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationStartsAt" date NULL;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationEndsAt" date NULL;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "HoursStreamed" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "HoursWatched" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "PeakViewers" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "FollowersGained" integer NULL;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
|
||||
|
||||
@@ -55,6 +82,9 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "SessionIdleTimeoutHours" integer NOT NULL DEFAULT 3;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '';
|
||||
|
||||
@@ -70,6 +100,9 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "Seasons"
|
||||
ADD COLUMN IF NOT EXISTS "SubcategoryTemplatesJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"SeasonId" integer NULL,
|
||||
@@ -254,6 +287,95 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "Candidates"
|
||||
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked';
|
||||
|
||||
ALTER TABLE "Categories"
|
||||
ADD COLUMN IF NOT EXISTS "ViewerRangeMin" integer NULL;
|
||||
|
||||
ALTER TABLE "Categories"
|
||||
ADD COLUMN IF NOT EXISTS "ViewerRangeMax" integer NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "StreamerIdentities" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"Platform" character varying(40) NOT NULL,
|
||||
"Login" character varying(120) NOT NULL,
|
||||
"NormalizedKey" character varying(180) NOT NULL,
|
||||
"DisplayName" character varying(120) NOT NULL,
|
||||
"ProfileUrl" character varying(500) NULL,
|
||||
"LastResolvedAt" timestamp with time zone NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_StreamerIdentities_NormalizedKey"
|
||||
ON "StreamerIdentities" ("NormalizedKey");
|
||||
|
||||
ALTER TABLE "Candidates"
|
||||
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
|
||||
|
||||
ALTER TABLE "Candidates"
|
||||
ADD COLUMN IF NOT EXISTS "NominationTally" integer NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_Candidates_StreamerIdentityId"
|
||||
ON "Candidates" ("StreamerIdentityId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_Candidates_StreamerIdentities_StreamerIdentityId'
|
||||
) THEN
|
||||
ALTER TABLE "Candidates"
|
||||
ADD CONSTRAINT "FK_Candidates_StreamerIdentities_StreamerIdentityId"
|
||||
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ALTER COLUMN "CategoryId" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "CategoryGroupName" character varying(80) NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE "Nominations" n
|
||||
SET "CategoryGroupName" = c."GroupName"
|
||||
FROM "Categories" c
|
||||
WHERE n."CategoryId" = c."Id"
|
||||
AND COALESCE(n."CategoryGroupName", '') = '';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "SuggestedCategoryId" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "ResolvedChannel" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "ResolvedPlatform" character varying(40) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "AvgViewers" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackerStatus" character varying(40) NOT NULL DEFAULT 'pending';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackerCheckedAt" timestamp with time zone NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewStatus" character varying(30) NOT NULL DEFAULT 'clear';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingFlagsJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewNote" character varying(1000) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewedByTwitchId" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewedAt" timestamp with time zone NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
|
||||
|
||||
@@ -269,6 +391,36 @@ public static class OperationalTablesBootstrapper
|
||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
|
||||
ON "Nominations" ("SeasonId", "Status");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_CategoryGroupName_Status"
|
||||
ON "Nominations" ("SeasonId", "CategoryGroupName", "Status");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName"
|
||||
ON "Nominations" ("SeasonId", "StreamerIdentityId", "CategoryGroupName");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_Nominations_StreamerIdentities_StreamerIdentityId'
|
||||
) THEN
|
||||
ALTER TABLE "Nominations"
|
||||
ADD CONSTRAINT "FK_Nominations_StreamerIdentities_StreamerIdentityId"
|
||||
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_Nominations_Categories_SuggestedCategoryId'
|
||||
) THEN
|
||||
ALTER TABLE "Nominations"
|
||||
ADD CONSTRAINT "FK_Nominations_Categories_SuggestedCategoryId"
|
||||
FOREIGN KEY ("SuggestedCategoryId") REFERENCES "Categories" ("Id")
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "TeamMembers" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"Login" character varying(80) NOT NULL,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
@@ -7,54 +8,137 @@ public static partial class SeedDataBootstrapper
|
||||
{
|
||||
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
|
||||
{
|
||||
var seasonCategories = await db.Categories
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToArrayAsync();
|
||||
.ToListAsync();
|
||||
var templates = SeedCatalog.DefaultSubcategoryTemplates
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
var usedCategories = new HashSet<Category>();
|
||||
|
||||
foreach (var category in seasonCategories)
|
||||
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||
|
||||
var nextSortOrder = 1;
|
||||
foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder))
|
||||
{
|
||||
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
|
||||
foreach (var template in templates)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var category = FindReusableCategory(categories, award, template, usedCategories)
|
||||
?? new Category { SeasonId = season.Id };
|
||||
usedCategories.Add(category);
|
||||
|
||||
var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug);
|
||||
category.GroupName = target.GroupName;
|
||||
category.Name = target.Name;
|
||||
category.Slug = target.Slug;
|
||||
category.Description = target.Description;
|
||||
category.SortOrder = target.SortOrder;
|
||||
category.MaxNomineesPerUser = 3;
|
||||
category.GroupName = award.Name;
|
||||
category.Name = template.Name;
|
||||
category.Slug = BuildCategorySlug(award.Slug, template.Slug);
|
||||
category.Description = award.Description;
|
||||
category.SortOrder = nextSortOrder++;
|
||||
category.MaxNomineesPerUser = 3;
|
||||
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||
|
||||
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
categories.Add(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var staleCategories = categories
|
||||
.Where(item => item.Id > 0 && !usedCategories.Contains(item))
|
||||
.ToArray();
|
||||
await RemoveStaleCategoryDataAsync(db, staleCategories);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var existing = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.Select(item => item.Slug)
|
||||
.ToArrayAsync();
|
||||
var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var seed in SeedCatalog.CategorySeeds)
|
||||
private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
|
||||
{
|
||||
if (staleCategories.Length == 0)
|
||||
{
|
||||
if (existingSlugs.Contains(seed.Slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
db.Categories.Add(new Category
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
GroupName = seed.GroupName,
|
||||
Name = seed.Name,
|
||||
Slug = seed.Slug,
|
||||
Description = seed.Description,
|
||||
SortOrder = seed.SortOrder,
|
||||
MaxNomineesPerUser = 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
|
||||
var staleCandidateIds = await db.Candidates
|
||||
.Where(item => staleCategoryIds.Contains(item.CategoryId))
|
||||
.Select(item => item.Id)
|
||||
.ToArrayAsync();
|
||||
|
||||
var staleVoteEntries = await db.VoteEntries
|
||||
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
|
||||
.ToArrayAsync();
|
||||
db.VoteEntries.RemoveRange(staleVoteEntries);
|
||||
|
||||
var staleResults = await db.Results
|
||||
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
|
||||
.ToArrayAsync();
|
||||
db.Results.RemoveRange(staleResults);
|
||||
|
||||
var affectedClips = await db.ClipSubmissions
|
||||
.Where(item =>
|
||||
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
|
||||
.ToArrayAsync();
|
||||
foreach (var clip in affectedClips)
|
||||
{
|
||||
if (clip.CategoryId != null && staleCategoryIds.Contains(clip.CategoryId.Value))
|
||||
{
|
||||
clip.CategoryId = null;
|
||||
}
|
||||
|
||||
if (clip.CandidateId != null && staleCandidateIds.Contains(clip.CandidateId.Value))
|
||||
{
|
||||
clip.CandidateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
var affectedNominations = await db.Nominations
|
||||
.Where(item =>
|
||||
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value))
|
||||
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
|
||||
.ToArrayAsync();
|
||||
foreach (var nomination in affectedNominations)
|
||||
{
|
||||
if (nomination.CategoryId != null && staleCategoryIds.Contains(nomination.CategoryId.Value))
|
||||
{
|
||||
nomination.CategoryId = null;
|
||||
}
|
||||
|
||||
if (nomination.SuggestedCategoryId != null && staleCategoryIds.Contains(nomination.SuggestedCategoryId.Value))
|
||||
{
|
||||
nomination.SuggestedCategoryId = null;
|
||||
}
|
||||
|
||||
if (nomination.CandidateId != null && staleCandidateIds.Contains(nomination.CandidateId.Value))
|
||||
{
|
||||
nomination.CandidateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
var staleCandidates = await db.Candidates
|
||||
.Where(item => staleCandidateIds.Contains(item.Id))
|
||||
.ToArrayAsync();
|
||||
db.Candidates.RemoveRange(staleCandidates);
|
||||
db.Categories.RemoveRange(staleCategories);
|
||||
}
|
||||
|
||||
private static Category? FindReusableCategory(
|
||||
List<Category> categories,
|
||||
AwardCategorySeed award,
|
||||
SeasonSubcategoryTemplateSetting template,
|
||||
HashSet<Category> usedCategories)
|
||||
{
|
||||
var targetSlug = BuildCategorySlug(award.Slug, template.Slug);
|
||||
|
||||
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, targetSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.GroupName, award.Name, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.GroupName, award.LegacyGroupName, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
|
||||
@@ -142,4 +226,7 @@ public static partial class SeedDataBootstrapper
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCategorySlug(string awardSlug, string templateSlug) =>
|
||||
$"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}";
|
||||
}
|
||||
|
||||
+64
-55
@@ -1,6 +1,8 @@
|
||||
namespace Backend.Data;
|
||||
|
||||
internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder);
|
||||
using Backend.Services;
|
||||
|
||||
internal sealed record AwardCategorySeed(string LegacyGroupName, string Name, string Slug, string Description, int SortOrder);
|
||||
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||
internal sealed record SiteFaqSeed(string Question, string Answer);
|
||||
@@ -8,10 +10,17 @@ internal sealed record SiteSocialSeed(string Label, string Platform, string Url,
|
||||
|
||||
internal static class SeedCatalog
|
||||
{
|
||||
internal static readonly CategorySeed[] CategorySeeds =
|
||||
internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates =
|
||||
[
|
||||
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1),
|
||||
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2),
|
||||
new("Hidden Star", "hidden-star", 1, 1, 20),
|
||||
new("Rising Star", "rising-star", 2, 21, 60),
|
||||
new("Shining Star", "shining-star", 3, 61, null),
|
||||
];
|
||||
|
||||
internal static readonly AwardCategorySeed[] AwardCategorySeeds =
|
||||
[
|
||||
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die größte Auszeichnung des Jahres.", 1),
|
||||
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie für die Szene.", 2),
|
||||
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
|
||||
new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
|
||||
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
|
||||
@@ -34,16 +43,16 @@ internal static class SeedCatalog
|
||||
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
|
||||
new(
|
||||
"Wie funktioniert das Voting?",
|
||||
"Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."),
|
||||
"Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase."),
|
||||
new(
|
||||
"Was kostet die Teilnahme?",
|
||||
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."),
|
||||
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans."),
|
||||
new(
|
||||
"Wann und wo findet die Award-Show statt?",
|
||||
"Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"),
|
||||
"Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!"),
|
||||
new(
|
||||
"Ich wurde nominiert — was nun?",
|
||||
"Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."),
|
||||
"Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt."),
|
||||
];
|
||||
|
||||
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
|
||||
@@ -60,18 +69,18 @@ Anbieter
|
||||
VTuber Star Awards, vertreten durch Jayuhime.
|
||||
|
||||
Kontakt
|
||||
Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||
Nutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||
|
||||
Hinweis
|
||||
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.
|
||||
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.
|
||||
""";
|
||||
|
||||
internal const string DefaultContactContent = """
|
||||
Kontakt zum Award-Team
|
||||
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.
|
||||
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.
|
||||
|
||||
Datenschutzfragen
|
||||
Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||
Für Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||
|
||||
Community & Kooperationen
|
||||
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||
@@ -79,57 +88,57 @@ Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||
|
||||
internal const string DefaultSponsorsContent = """
|
||||
Sponsoren & Partner
|
||||
Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
||||
Hier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
||||
|
||||
Partner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.
|
||||
Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.
|
||||
""";
|
||||
|
||||
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
||||
[
|
||||
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
||||
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new("best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("community-liebling", "Lumi", "@lumi_vt", "Cake"),
|
||||
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||
new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||
new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||
new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||
new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||
new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||
];
|
||||
|
||||
internal static readonly WinnerSeed[] WinnerSeeds =
|
||||
[
|
||||
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
||||
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"),
|
||||
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"),
|
||||
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"),
|
||||
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"),
|
||||
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"),
|
||||
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
||||
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"),
|
||||
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"),
|
||||
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"),
|
||||
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"),
|
||||
new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||
new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"),
|
||||
new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||
new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"),
|
||||
new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"),
|
||||
new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||
new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"),
|
||||
new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||
new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||
new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||
new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"),
|
||||
new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"),
|
||||
new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
|
||||
new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,15 +49,23 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
||||
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
|
||||
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
|
||||
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
|
||||
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.",
|
||||
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
|
||||
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults),
|
||||
TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
|
||||
TrackingRulesSettings.DefaultSource,
|
||||
TrackingRulesSettings.DefaultImportantMetrics,
|
||||
TrackingRulesSettings.DefaultOptionalMetrics,
|
||||
TrackingRulesSettings.DefaultFlags)),
|
||||
ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl,
|
||||
TrackingReviewNotes = "Fallback-Quellen für manuelle Reviews:\\n- SullyGnome\\n- Twitch-Kanal direkt\\n\\nNutze diese Notizen für Edge Cases und manuelle Tier-Entscheidungen.",
|
||||
NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
|
||||
ClipSubmissionsEnabled = false,
|
||||
ClipReviewEnabled = true,
|
||||
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
|
||||
ShowactApplicationsEnabled = false,
|
||||
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
|
||||
SessionIdleTimeoutHours = 3,
|
||||
SponsorsVisible = true,
|
||||
SocialLinksJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
@@ -152,7 +160,7 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Category>().HasData(
|
||||
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die groesste Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die größte Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 },
|
||||
|
||||
@@ -16,6 +16,7 @@ public static partial class SeedDataBootstrapper
|
||||
.ToArrayAsync();
|
||||
|
||||
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
|
||||
await EnsureSeedReviewNominationsAsync(db, season, categories, candidates);
|
||||
|
||||
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
|
||||
{
|
||||
@@ -23,15 +24,15 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"),
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"),
|
||||
SubmittedByTwitchId = "local_user_3",
|
||||
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
|
||||
Title = "Starlight Debut Moment",
|
||||
Creator = "Hoshimi Miyu",
|
||||
Platform = "Twitch",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
||||
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
|
||||
@@ -40,15 +41,15 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"),
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"),
|
||||
SubmittedByTwitchId = "local_user_4",
|
||||
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
|
||||
Title = "Finale-Hype mit Chat-Chaos",
|
||||
Creator = "Kurainu",
|
||||
Platform = "Twitch",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
||||
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
|
||||
@@ -57,8 +58,8 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "best-gaming"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"),
|
||||
CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"),
|
||||
SubmittedByTwitchId = "local_user",
|
||||
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
|
||||
Title = "Epischer Clutch im Finale",
|
||||
@@ -71,15 +72,15 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "gesang-musik"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"),
|
||||
CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"),
|
||||
SubmittedByTwitchId = "local_user_2",
|
||||
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
|
||||
Title = "Live-Cover mit Gänsehaut",
|
||||
Creator = "Melo Diva",
|
||||
Platform = "YouTube",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Review-Workflow.",
|
||||
ReviewNote = "Geprüfter Clip für Review-Workflow.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
|
||||
@@ -114,7 +115,7 @@ public static partial class SeedDataBootstrapper
|
||||
EntityType = "database",
|
||||
EntityId = season.Year.ToString(),
|
||||
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
|
||||
MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }),
|
||||
MetadataJson = JsonSerializer.Serialize(new { awardCategories = SeedCatalog.AwardCategorySeeds.Length, subcategories = SeedCatalog.DefaultSubcategoryTemplates.Length }),
|
||||
CreatedFromIp = "seed",
|
||||
UserAgent = "seed-bootstrap",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
|
||||
@@ -279,5 +280,275 @@ public static partial class SeedDataBootstrapper
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.ToArray());
|
||||
|
||||
private static async Task EnsureSeedReviewNominationsAsync(
|
||||
AwardsDbContext db,
|
||||
Season season,
|
||||
IReadOnlyDictionary<string, Category> categories,
|
||||
Candidate[] candidates)
|
||||
{
|
||||
var staleSeedNominations = await db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == season.Id
|
||||
&& (
|
||||
item.SubmittedByTwitchId.StartsWith("seed_review_")
|
||||
|| item.SubmittedByTwitchId == "twitch_hoshi"
|
||||
|| item.SubmittedByTwitchId == "twitch_kurainu"
|
||||
|| item.SubmittedByTwitchId.StartsWith("demo_user")
|
||||
|| item.SubmittedByTwitchId.StartsWith("local_user")
|
||||
))
|
||||
.ToArrayAsync();
|
||||
|
||||
if (staleSeedNominations.Length > 0)
|
||||
{
|
||||
db.Nominations.RemoveRange(staleSeedNominations);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var orderedCategories = categories.Values
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToArray();
|
||||
var candidatesByCategoryId = candidates
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.ToDictionary(
|
||||
grouping => grouping.Key,
|
||||
grouping => grouping.OrderBy(item => item.DisplayName).ToArray());
|
||||
|
||||
var seedNominations = new List<Nomination>();
|
||||
var createdAt = new DateTimeOffset(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
foreach (var category in orderedCategories)
|
||||
{
|
||||
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
||||
var existingCandidate = categoryCandidates?.FirstOrDefault();
|
||||
|
||||
seedNominations.AddRange(BuildPendingSeedGroup(
|
||||
category,
|
||||
existingCandidate,
|
||||
groupKey: "existing",
|
||||
firstSubmitter: $"seed_review_{category.Slug}_existing_a",
|
||||
secondSubmitter: $"seed_review_{category.Slug}_existing_b",
|
||||
createdAt,
|
||||
useSuggestedCategory: true,
|
||||
trackerStatus: "resolved"));
|
||||
createdAt = createdAt.AddMinutes(8);
|
||||
|
||||
seedNominations.AddRange(BuildPendingSeedGroup(
|
||||
category,
|
||||
existingCandidate: null,
|
||||
groupKey: "fresh",
|
||||
firstSubmitter: $"seed_review_{category.Slug}_fresh_a",
|
||||
secondSubmitter: $"seed_review_{category.Slug}_fresh_b",
|
||||
createdAt,
|
||||
useSuggestedCategory: false,
|
||||
trackerStatus: "unsupported_platform"));
|
||||
createdAt = createdAt.AddMinutes(8);
|
||||
}
|
||||
|
||||
foreach (var category in orderedCategories.Take(2))
|
||||
{
|
||||
seedNominations.AddRange(BuildReviewedSeedGroup(
|
||||
category,
|
||||
status: "rejected",
|
||||
displayName: $"{category.GroupName} Review Return",
|
||||
submittedByPrefix: $"seed_review_{category.Slug}_rejected",
|
||||
createdAt,
|
||||
reviewedByTwitchId: "jayuhime_admin",
|
||||
candidateId: null,
|
||||
candidateDisplayName: null));
|
||||
createdAt = createdAt.AddMinutes(10);
|
||||
}
|
||||
|
||||
foreach (var category in orderedCategories.Skip(2).Take(2))
|
||||
{
|
||||
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
||||
var candidate = categoryCandidates?.FirstOrDefault();
|
||||
seedNominations.AddRange(BuildReviewedSeedGroup(
|
||||
category,
|
||||
status: "approved",
|
||||
displayName: candidate?.DisplayName ?? $"{category.GroupName} Approved Pick",
|
||||
submittedByPrefix: $"seed_review_{category.Slug}_approved",
|
||||
createdAt,
|
||||
reviewedByTwitchId: "jayuhime_admin",
|
||||
candidateId: candidate?.Id,
|
||||
candidateDisplayName: candidate?.DisplayName));
|
||||
createdAt = createdAt.AddMinutes(10);
|
||||
}
|
||||
|
||||
db.Nominations.AddRange(seedNominations);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static IEnumerable<Nomination> BuildPendingSeedGroup(
|
||||
Category category,
|
||||
Candidate? existingCandidate,
|
||||
string groupKey,
|
||||
string firstSubmitter,
|
||||
string secondSubmitter,
|
||||
DateTimeOffset createdAt,
|
||||
bool useSuggestedCategory,
|
||||
string trackerStatus)
|
||||
{
|
||||
var displayName = existingCandidate?.DisplayName ?? BuildFreshSeedName(category, groupKey);
|
||||
var platform = existingCandidate?.Platform ?? "YouTube";
|
||||
var channelSlug = existingCandidate?.ChannelSlug ?? BuildSeedChannelSlug(category, groupKey);
|
||||
var streamUrl = BuildSeedStreamUrl(platform, channelSlug);
|
||||
var avgViewers = ResolveSeedViewerValue(category);
|
||||
int? suggestedCategoryId = useSuggestedCategory ? category.Id : null;
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = firstSubmitter,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = platform,
|
||||
AvgViewers = avgViewers,
|
||||
SuggestedCategoryId = suggestedCategoryId,
|
||||
TrackerStatus = trackerStatus,
|
||||
TrackerCheckedAt = createdAt.AddMinutes(2),
|
||||
TrackingReviewStatus = "clear",
|
||||
Status = "pending",
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = secondSubmitter,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = platform,
|
||||
AvgViewers = avgViewers,
|
||||
SuggestedCategoryId = suggestedCategoryId,
|
||||
TrackerStatus = trackerStatus,
|
||||
TrackerCheckedAt = createdAt.AddMinutes(3),
|
||||
TrackingReviewStatus = "clear",
|
||||
Status = "pending",
|
||||
CreatedAt = createdAt.AddMinutes(1),
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<Nomination> BuildReviewedSeedGroup(
|
||||
Category category,
|
||||
string status,
|
||||
string displayName,
|
||||
string submittedByPrefix,
|
||||
DateTimeOffset createdAt,
|
||||
string reviewedByTwitchId,
|
||||
int? candidateId,
|
||||
string? candidateDisplayName)
|
||||
{
|
||||
var channelSlug = BuildSeedChannelSlug(category, $"{status}_{displayName}");
|
||||
var streamUrl = BuildSeedStreamUrl("Twitch", channelSlug);
|
||||
var reviewNote = status == "approved"
|
||||
? "Seed-Datensatz: bereits als Kandidat übernommen."
|
||||
: "Seed-Datensatz: bewusst verworfen für Undo-Tests.";
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = $"{submittedByPrefix}_a",
|
||||
CandidateId = candidateId,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = "Twitch",
|
||||
AvgViewers = ResolveSeedViewerValue(category),
|
||||
SuggestedCategoryId = category.Id,
|
||||
TrackerStatus = "resolved",
|
||||
TrackerCheckedAt = createdAt.AddMinutes(2),
|
||||
TrackingReviewStatus = "reviewed",
|
||||
TrackingReviewNote = reviewNote,
|
||||
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
||||
TrackingReviewedAt = createdAt.AddMinutes(4),
|
||||
Status = status,
|
||||
ReviewNote = reviewNote,
|
||||
ReviewedByTwitchId = reviewedByTwitchId,
|
||||
CreatedAt = createdAt,
|
||||
ReviewedAt = createdAt.AddMinutes(4),
|
||||
};
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = $"{submittedByPrefix}_b",
|
||||
CandidateId = candidateId,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = "Twitch",
|
||||
AvgViewers = ResolveSeedViewerValue(category),
|
||||
SuggestedCategoryId = category.Id,
|
||||
TrackerStatus = "resolved",
|
||||
TrackerCheckedAt = createdAt.AddMinutes(3),
|
||||
TrackingReviewStatus = "reviewed",
|
||||
TrackingReviewNote = reviewNote,
|
||||
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
||||
TrackingReviewedAt = createdAt.AddMinutes(5),
|
||||
Status = status,
|
||||
ReviewNote = reviewNote,
|
||||
ReviewedByTwitchId = reviewedByTwitchId,
|
||||
CreatedAt = createdAt.AddMinutes(1),
|
||||
ReviewedAt = createdAt.AddMinutes(5),
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildFreshSeedName(Category category, string groupKey) =>
|
||||
groupKey switch
|
||||
{
|
||||
"fresh" => $"{category.GroupName} Spotlight {category.Name}",
|
||||
_ => $"{category.GroupName} {category.Name} Pick",
|
||||
};
|
||||
|
||||
private static string BuildSeedChannelSlug(Category category, string suffix)
|
||||
{
|
||||
var raw = $"{category.Slug}-{suffix}"
|
||||
.Trim()
|
||||
.TrimStart('@')
|
||||
.ToLowerInvariant();
|
||||
|
||||
return new string(raw.Where(char.IsLetterOrDigit).ToArray());
|
||||
}
|
||||
|
||||
private static string BuildSeedStreamUrl(string platform, string channelSlug) =>
|
||||
platform.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"youtube" => $"https://www.youtube.com/@{channelSlug}",
|
||||
"kick" => $"https://kick.com/{channelSlug}",
|
||||
"cake" => $"https://cake.gg/{channelSlug}",
|
||||
_ => $"https://www.twitch.tv/{channelSlug}",
|
||||
};
|
||||
|
||||
private static int ResolveSeedViewerValue(Category category)
|
||||
{
|
||||
if (category.ViewerRangeMin is int min && category.ViewerRangeMax is int max)
|
||||
{
|
||||
return min + ((max - min) / 2);
|
||||
}
|
||||
|
||||
if (category.ViewerRangeMin is int lowerBound)
|
||||
{
|
||||
return lowerBound + 12;
|
||||
}
|
||||
|
||||
if (category.ViewerRangeMax is int upperBound)
|
||||
{
|
||||
return Math.Max(1, upperBound - 5);
|
||||
}
|
||||
|
||||
return 25;
|
||||
}
|
||||
|
||||
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,34 @@ public static partial class SeedDataBootstrapper
|
||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults);
|
||||
}
|
||||
|
||||
if (!HasValidTrackingRules(settings.TrackingRulesJson))
|
||||
{
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
|
||||
TrackingRulesSettings.DefaultSource,
|
||||
TrackingRulesSettings.DefaultImportantMetrics,
|
||||
TrackingRulesSettings.DefaultOptionalMetrics,
|
||||
TrackingRulesSettings.DefaultFlags));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.ViewerStatsProviderBaseUrl))
|
||||
{
|
||||
settings.ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.TrackingReviewNotes))
|
||||
{
|
||||
settings.TrackingReviewNotes = """
|
||||
Fallback-Quellen für manuelle Reviews:
|
||||
- SullyGnome Channel Summary
|
||||
- Offizieller Twitch-Kanal
|
||||
|
||||
Prüfe bei Edge Cases:
|
||||
- passt der Kanal wirklich zur Unterkategorie?
|
||||
- fehlen TwitchTracker-Daten nur temporär?
|
||||
- braucht der Fall eine manuelle Team-Notiz?
|
||||
""";
|
||||
}
|
||||
|
||||
if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
|
||||
{
|
||||
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
|
||||
@@ -83,7 +111,12 @@ public static partial class SeedDataBootstrapper
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
|
||||
{
|
||||
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.";
|
||||
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.";
|
||||
}
|
||||
|
||||
if (settings.SessionIdleTimeoutHours < 3)
|
||||
{
|
||||
settings.SessionIdleTimeoutHours = 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +154,14 @@ public static partial class SeedDataBootstrapper
|
||||
private static bool HasValidWorkflowRules(string? json) =>
|
||||
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length;
|
||||
|
||||
private static bool HasValidTrackingRules(string? json)
|
||||
{
|
||||
var rules = TrackingRulesSettings.Read(new Backend.Domain.SiteSettings { TrackingRulesJson = json ?? string.Empty });
|
||||
return rules.ImportantMetrics.Length == TrackingRulesSettings.DefaultImportantMetrics.Length
|
||||
&& rules.OptionalMetrics.Length == TrackingRulesSettings.DefaultOptionalMetrics.Length
|
||||
&& rules.Flags.Length == TrackingRulesSettings.DefaultFlags.Length;
|
||||
}
|
||||
|
||||
private static bool HasValidNominationLinkBlacklist(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
||||
@@ -7,9 +7,12 @@ public sealed class Candidate
|
||||
public Season Season { get; set; } = null!;
|
||||
public int CategoryId { get; set; }
|
||||
public Category Category { get; set; } = null!;
|
||||
public int? StreamerIdentityId { get; set; }
|
||||
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string ChannelSlug { get; set; } = string.Empty;
|
||||
public string Platform { get; set; } = "Twitch";
|
||||
public int NominationTally { get; set; }
|
||||
public string AcceptanceStatus { get; set; } = "open";
|
||||
public string? AcceptanceNote { get; set; }
|
||||
public string? ClipCompilationUrl { get; set; }
|
||||
|
||||
@@ -11,5 +11,7 @@ public sealed class Category
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public int MaxNomineesPerUser { get; set; }
|
||||
public int? ViewerRangeMin { get; set; }
|
||||
public int? ViewerRangeMax { get; set; }
|
||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -5,13 +5,32 @@ public sealed class Nomination
|
||||
public int Id { get; set; }
|
||||
public int SeasonId { get; set; }
|
||||
public Season Season { get; set; } = null!;
|
||||
public int CategoryId { get; set; }
|
||||
public Category Category { get; set; } = null!;
|
||||
public int? CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
public string CategoryGroupName { get; set; } = string.Empty;
|
||||
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
||||
public int? CandidateId { get; set; }
|
||||
public Candidate? Candidate { get; set; }
|
||||
public int? StreamerIdentityId { get; set; }
|
||||
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||
public int? SuggestedCategoryId { get; set; }
|
||||
public Category? SuggestedCategory { get; set; }
|
||||
public string? CandidateText { get; set; }
|
||||
public string? StreamUrl { get; set; }
|
||||
public string? ResolvedChannel { get; set; }
|
||||
public string? ResolvedPlatform { get; set; }
|
||||
public int? AvgViewers { get; set; }
|
||||
public int? HoursStreamed { get; set; }
|
||||
public int? HoursWatched { get; set; }
|
||||
public int? PeakViewers { get; set; }
|
||||
public int? FollowersGained { get; set; }
|
||||
public string TrackerStatus { get; set; } = "pending";
|
||||
public DateTimeOffset? TrackerCheckedAt { get; set; }
|
||||
public string TrackingReviewStatus { get; set; } = "clear";
|
||||
public string TrackingFlagsJson { get; set; } = "[]";
|
||||
public string? TrackingReviewNote { get; set; }
|
||||
public string? TrackingReviewedByTwitchId { get; set; }
|
||||
public DateTimeOffset? TrackingReviewedAt { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? ReviewNote { get; set; }
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class Season
|
||||
public DateOnly ReviewEndsAt { get; set; }
|
||||
public DateOnly ShowDate { get; set; }
|
||||
public TimeOnly ShowStartsAt { get; set; } = new(20, 0);
|
||||
public string SubcategoryTemplatesJson { get; set; } = "[]";
|
||||
public string WorkflowRulesJson { get; set; } = "[]";
|
||||
public ICollection<Category> Categories { get; set; } = [];
|
||||
public ICollection<AwardResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class ShowactApplication
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string TechnicalNotes { get; set; } = string.Empty;
|
||||
public string ReferenceUrl { get; set; } = string.Empty;
|
||||
public string FieldResponsesJson { get; set; } = "{}";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? ReviewNote { get; set; }
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class SiteSettings
|
||||
public string HostDisplayName { get; set; } = string.Empty;
|
||||
public string HostTagline { get; set; } = string.Empty;
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ShareXUrl { get; set; } = string.Empty;
|
||||
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||
public string PrivacyEmail { get; set; } = string.Empty;
|
||||
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
||||
public string? PrivacyPolicyUpdatedBy { get; set; }
|
||||
@@ -22,13 +24,19 @@ public sealed class SiteSettings
|
||||
public string FaqJson { get; set; } = "[]";
|
||||
public string RiskRulesJson { get; set; } = "[]";
|
||||
public string WorkflowRulesJson { get; set; } = "[]";
|
||||
public string TrackingRulesJson { get; set; } = "[]";
|
||||
public string ViewerStatsProviderBaseUrl { get; set; } = string.Empty;
|
||||
public string TrackingReviewNotes { get; set; } = string.Empty;
|
||||
public string NominationLinkBlacklistJson { get; set; } = "[]";
|
||||
public bool ClipSubmissionsEnabled { get; set; }
|
||||
public bool ClipReviewEnabled { get; set; } = true;
|
||||
public bool ClipAdminMenuVisible { get; set; } = true;
|
||||
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||
public bool ShowactApplicationsEnabled { get; set; }
|
||||
public DateOnly? ShowactApplicationStartsAt { get; set; }
|
||||
public DateOnly? ShowactApplicationEndsAt { get; set; }
|
||||
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
|
||||
public string ShowactFormSchemaJson { get; set; } = "[]";
|
||||
public bool SponsorsVisible { get; set; } = true;
|
||||
public bool DemoLoginManagedByDatabase { get; set; }
|
||||
public bool DemoLoginEnabled { get; set; }
|
||||
@@ -42,6 +50,7 @@ public sealed class SiteSettings
|
||||
public string TwitchClientSecret { get; set; } = string.Empty;
|
||||
public string TwitchRedirectUri { get; set; } = string.Empty;
|
||||
public string TwitchScope { get; set; } = string.Empty;
|
||||
public int SessionIdleTimeoutHours { get; set; } = 3;
|
||||
public bool MaintenanceModeEnabled { get; set; }
|
||||
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
||||
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class StreamerIdentity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
public string Login { get; set; } = string.Empty;
|
||||
public string NormalizedKey { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? ProfileUrl { get; set; }
|
||||
public DateTimeOffset? LastResolvedAt { get; set; }
|
||||
public ICollection<Nomination> Nominations { get; set; } = [];
|
||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||
}
|
||||
@@ -303,7 +303,8 @@ public static class AdminExtrasEndpoints
|
||||
application.Status,
|
||||
application.ReviewNote,
|
||||
application.CreatedAt,
|
||||
application.ReviewedAt);
|
||||
application.ReviewedAt,
|
||||
application.FieldResponsesJson ?? "{}");
|
||||
|
||||
private static string NormalizeStatus(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
||||
|
||||
@@ -20,6 +20,14 @@ public static partial class AdminModerationEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("ReopenRejectedAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("UpdateNominationTrackingReview")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("GetAdminNominationLinkBlacklist")
|
||||
|
||||
@@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations
|
||||
.Include(item => item.Category)
|
||||
.Include(item => item.SuggestedCategory)
|
||||
.Include(item => item.StreamerIdentity)
|
||||
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||
|
||||
if (nomination is null)
|
||||
@@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Any(flag => flag.BlocksApproval)
|
||||
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
|
||||
}
|
||||
|
||||
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
||||
}
|
||||
|
||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
||||
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
|
||||
if (!categoryId.HasValue)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
|
||||
}
|
||||
|
||||
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||
item.Id == categoryId.Value
|
||||
&& item.SeasonId == nomination.SeasonId
|
||||
&& item.GroupName == nomination.CategoryGroupName);
|
||||
if (targetCategory is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
|
||||
}
|
||||
|
||||
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform)
|
||||
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
|
||||
: request.Platform.Trim();
|
||||
var normalizedDisplayName = rawDisplayName.ToLower();
|
||||
var normalizedChannelSlug = channelSlug.ToLower();
|
||||
var normalizedPlatform = platform.ToLower();
|
||||
|
||||
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryId == nomination.CategoryId
|
||||
&& item.CategoryId == targetCategory.Id
|
||||
&& (
|
||||
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
||
|
||||
item.DisplayName.ToLower() == normalizedDisplayName
|
||||
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||
&& item.Platform.ToLower() == normalizedPlatform)
|
||||
));
|
||||
|
||||
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
nomination.SeasonId,
|
||||
targetCategory.Id,
|
||||
existingCandidate?.Id,
|
||||
nomination.StreamerIdentityId,
|
||||
rawDisplayName,
|
||||
channelSlug,
|
||||
existingCandidate?.AcceptanceStatus ?? "open",
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
var candidate = existingCandidate;
|
||||
if (candidate is null)
|
||||
{
|
||||
candidate = new Candidate
|
||||
{
|
||||
SeasonId = nomination.SeasonId,
|
||||
CategoryId = nomination.CategoryId,
|
||||
CategoryId = targetCategory.Id,
|
||||
StreamerIdentityId = nomination.StreamerIdentityId,
|
||||
DisplayName = rawDisplayName,
|
||||
ChannelSlug = channelSlug,
|
||||
Platform = platform,
|
||||
@@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
|
||||
candidate.DisplayName = rawDisplayName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||
@@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
nomination.CandidateId = candidate.Id;
|
||||
nomination.Status = "approved";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var uniqueViewerCount = relatedNominations
|
||||
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
|
||||
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = candidate.Id;
|
||||
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
|
||||
relatedNomination.Status = "approved";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.approve",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
|
||||
new
|
||||
{
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
targetCategoryId = targetCategory.Id,
|
||||
targetCategoryName = targetCategory.Name,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
uniqueViewerCount,
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
nominationId = nomination.Id,
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
uniqueViewerCount,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectNomination(
|
||||
@@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
nomination.CandidateId = null;
|
||||
nomination.Status = "rejected";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "rejected";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.",
|
||||
new { nomination.ReviewNote },
|
||||
new
|
||||
{
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReopenRejectedNomination(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
ReopenRejectedNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "pending";
|
||||
relatedNomination.ReviewNote = null;
|
||||
relatedNomination.ReviewedAt = null;
|
||||
relatedNomination.ReviewedByTwitchId = null;
|
||||
}
|
||||
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.reopen",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.",
|
||||
new
|
||||
{
|
||||
reviewNote,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateNominationTrackingReview(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
UpdateNominationTrackingReviewRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedStatus = request.Status?.Trim().ToLowerInvariant();
|
||||
if (normalizedStatus is not ("reviewed" or "overridden"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
var requiresOverrideNote = relatedNominations
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.Any(flag => flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." });
|
||||
}
|
||||
|
||||
foreach (var item in relatedNominations)
|
||||
{
|
||||
item.TrackingReviewStatus = normalizedStatus;
|
||||
item.TrackingReviewNote = reviewNote;
|
||||
item.TrackingReviewedByTwitchId = session.TwitchUserId;
|
||||
item.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.tracking-review.update",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.",
|
||||
new
|
||||
{
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
status = normalizedStatus,
|
||||
reviewNote,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId, status = normalizedStatus });
|
||||
}
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedPendingNominationsAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
CancellationToken cancellationToken)
|
||||
=> await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken);
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedNominationsByStatusAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
string status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryGroupName == nomination.CategoryGroupName
|
||||
&& item.Status == status);
|
||||
|
||||
if (nomination.StreamerIdentityId.HasValue)
|
||||
{
|
||||
return await query
|
||||
.Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedStreamUrl))
|
||||
{
|
||||
var rows = await query.ToArrayAsync(cancellationToken);
|
||||
return rows
|
||||
.Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [nomination];
|
||||
}
|
||||
|
||||
private static string NormalizeModerationStreamUrl(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values) =>
|
||||
values
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||
?? string.Empty;
|
||||
|
||||
private static async Task<IResult?> BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
int? streamerIdentityId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
var rules = WorkflowRuleSettings.Read(season, settings);
|
||||
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingCandidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
|
||||
&& item.AcceptanceStatus != "declined")
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||
{
|
||||
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||
if (categoryCount >= finalistsRule.Limit)
|
||||
{
|
||||
return CreateModerationWorkflowRuleError(
|
||||
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
|
||||
}
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateModerationWorkflowRuleError(
|
||||
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult CreateModerationWorkflowRuleError(string message) =>
|
||||
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||
|
||||
private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Length == 0)
|
||||
{
|
||||
nomination.TrackingReviewStatus = "clear";
|
||||
nomination.TrackingReviewNote = null;
|
||||
nomination.TrackingReviewedByTwitchId = null;
|
||||
nomination.TrackingReviewedAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim();
|
||||
nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note))
|
||||
? "overridden"
|
||||
: "reviewed";
|
||||
nomination.TrackingReviewNote = note;
|
||||
nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId;
|
||||
nomination.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
seasonId,
|
||||
request.CategoryId,
|
||||
null,
|
||||
null,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
@@ -158,6 +159,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
candidate.SeasonId,
|
||||
request.CategoryId,
|
||||
candidateId,
|
||||
candidate.StreamerIdentityId,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
|
||||
@@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = request.ViewerRangeMin,
|
||||
ViewerRangeMax = request.ViewerRangeMax,
|
||||
};
|
||||
|
||||
db.Categories.Add(category);
|
||||
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = request.ViewerRangeMin;
|
||||
category.ViewerRangeMax = request.ViewerRangeMax;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> UpdateSeasonSubcategoryTemplates(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateSeasonSubcategoryTemplatesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateSubcategoryTemplatesRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
var blockedRemovals = await FindBlockedSubcategoryRemovalsAsync(db, categories, templates, context.RequestAborted);
|
||||
if (blockedRemovals.Length > 0)
|
||||
{
|
||||
var firstBlocked = blockedRemovals[0];
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = $"Unterkategorie \"{firstBlocked.SubcategoryName}\" kann nicht entfernt werden, weil darunter noch {firstBlocked.CandidateCount} Kandidaten und {firstBlocked.NominationCount} Nominierungen haengen.",
|
||||
blockedSubcategories = blockedRemovals,
|
||||
});
|
||||
}
|
||||
|
||||
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-templates.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Unterkategorien für {season.Year} wurden aktualisiert.",
|
||||
new { seasonId, templateCount = templates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, templateCount = templates.Length });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Lege zuerst mindestens eine globale Unterkategorie an." });
|
||||
}
|
||||
|
||||
var groupName = request.GroupName.Trim();
|
||||
if (categories.Any(item => string.Equals(item.GroupName, groupName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
categories.Add(new Category
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
GroupName = groupName,
|
||||
Name = templates[index].Name,
|
||||
Slug = BuildCategorySlug(groupName, templates[index].Slug),
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = templates[index].ViewerRangeMin,
|
||||
ViewerRangeMax = templates[index].ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var category in categories.Where(item => item.Id == 0))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.create",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {groupName} wurde angelegt.",
|
||||
new { seasonId, groupName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
var normalizedCurrentName = groupName.Trim();
|
||||
var groupCategories = categories
|
||||
.Where(item => string.Equals(item.GroupName, normalizedCurrentName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var targetName = request.GroupName.Trim();
|
||||
if (!string.Equals(normalizedCurrentName, targetName, StringComparison.OrdinalIgnoreCase)
|
||||
&& categories.Any(item => string.Equals(item.GroupName, targetName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
foreach (var category in groupCategories)
|
||||
{
|
||||
category.GroupName = targetName;
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedCurrentName} wurde aktualisiert.",
|
||||
new { seasonId, from = normalizedCurrentName, to = targetName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName = targetName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var normalizedGroupName = groupName.Trim();
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName.ToLower() == normalizedGroupName.ToLower())
|
||||
.ToListAsync(context.RequestAborted);
|
||||
if (categories.Count == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categoryIds = categories.Select(item => item.Id).ToArray();
|
||||
var candidates = await db.Candidates
|
||||
.Where(item => categoryIds.Contains(item.CategoryId))
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
|
||||
db.Categories.RemoveRange(categories);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.delete",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedGroupName} wurde gelöscht.",
|
||||
new { seasonId, removedCategories = categories.Count, removedCandidates = candidates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, seasonId, groupName = normalizedGroupName });
|
||||
}
|
||||
|
||||
private static void SyncCategoryGroupsToTemplates(
|
||||
AwardsDbContext db,
|
||||
Season season,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates)
|
||||
{
|
||||
var orderedGroups = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var items = group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList();
|
||||
var sample = items[0];
|
||||
return new
|
||||
{
|
||||
GroupName = sample.GroupName.Trim(),
|
||||
Description = sample.Description.Trim(),
|
||||
SortOrder = items.Min(item => item.SortOrder),
|
||||
MaxNomineesPerUser = sample.MaxNomineesPerUser,
|
||||
Items = items,
|
||||
};
|
||||
})
|
||||
.OrderBy(group => group.SortOrder)
|
||||
.ThenBy(group => group.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var nextSortOrder = 1;
|
||||
foreach (var group in orderedGroups)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
var template = templates[index];
|
||||
var category = FindReusableCategoryForTemplate(group.Items, template, group.GroupName, usedCategories)
|
||||
?? new Category { SeasonId = season.Id };
|
||||
usedCategories.Add(category);
|
||||
|
||||
category.GroupName = group.GroupName;
|
||||
category.Name = template.Name;
|
||||
category.Slug = BuildCategorySlug(group.GroupName, template.Slug);
|
||||
category.Description = group.Description;
|
||||
category.MaxNomineesPerUser = group.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||
category.SortOrder = nextSortOrder++;
|
||||
|
||||
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
categories.Add(category);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var staleCategory in FindStaleCategoriesAfterTemplateSync(group.Items, templates, group.GroupName))
|
||||
{
|
||||
RemoveCategoryWithCandidates(db, staleCategory);
|
||||
categories.Remove(staleCategory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveCategoryWithCandidates(AwardsDbContext db, Category category)
|
||||
{
|
||||
if (category.Id > 0)
|
||||
{
|
||||
var candidates = db.Candidates.Where(item => item.CategoryId == category.Id).ToArray();
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
db.Categories.Remove(category);
|
||||
}
|
||||
|
||||
private static async Task<BlockedSubcategoryRemoval[]> FindBlockedSubcategoryRemovalsAsync(
|
||||
AwardsDbContext db,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var staleCategories = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(group => FindStaleCategoriesAfterTemplateSync(
|
||||
group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(),
|
||||
templates,
|
||||
group.Key))
|
||||
.Where(item => item.Id > 0)
|
||||
.ToArray();
|
||||
|
||||
if (staleCategories.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
|
||||
var candidateCounts = await db.Candidates
|
||||
.Where(item => staleCategoryIds.Contains(item.CategoryId))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
var nominationCounts = await db.Nominations
|
||||
.Where(item =>
|
||||
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value)))
|
||||
.GroupBy(item => item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)
|
||||
? item.CategoryId!.Value
|
||||
: item.SuggestedCategoryId!.Value)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
|
||||
return staleCategories
|
||||
.Select(category => new BlockedSubcategoryRemoval(
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Slug,
|
||||
candidateCounts.GetValueOrDefault(category.Id),
|
||||
nominationCounts.GetValueOrDefault(category.Id)))
|
||||
.Where(item => item.CandidateCount > 0 || item.NominationCount > 0)
|
||||
.OrderBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.SubcategoryName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Category? FindReusableCategoryForTemplate(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting template,
|
||||
string groupName,
|
||||
HashSet<Category>? usedCategories = null)
|
||||
{
|
||||
usedCategories ??= [];
|
||||
var expectedSlug = BuildCategorySlug(groupName, template.Slug);
|
||||
var normalizedTemplateSlug = SeasonSubcategoryTemplateSettings.Slugify(template.Slug);
|
||||
|
||||
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, expectedSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, normalizedTemplateSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& item.Slug.EndsWith($"-{normalizedTemplateSlug}", StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static Category[] FindStaleCategoriesAfterTemplateSync(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
string groupName)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var reusableCategory = FindReusableCategoryForTemplate(categories, template, groupName, usedCategories);
|
||||
if (reusableCategory is not null)
|
||||
{
|
||||
usedCategories.Add(reusableCategory);
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
.Where(item => !usedCategories.Contains(item))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IResult? ValidateSubcategoryTemplatesRequest(UpdateSeasonSubcategoryTemplatesRequest request)
|
||||
{
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Mindestens eine Unterkategorie ist erforderlich." });
|
||||
}
|
||||
|
||||
var duplicateNames = templates
|
||||
.GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateNames)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Namen mehrfach verwenden." });
|
||||
}
|
||||
|
||||
var duplicateSlugs = templates
|
||||
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateSlugs)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Slug mehrfach verwenden." });
|
||||
}
|
||||
|
||||
foreach (var template in templates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Name ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(template.Slug) || template.Slug.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Slug ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (template.ViewerRangeMax is not null
|
||||
&& template.ViewerRangeMin is not null
|
||||
&& template.ViewerRangeMax < template.ViewerRangeMin)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer-Range Ende muss groesser oder gleich dem Start sein." });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult? ValidateCategoryGroupRequest(UpsertCategoryGroupRequest request)
|
||||
{
|
||||
var groupName = request.GroupName.Trim();
|
||||
var description = request.Description.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > 80)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Hauptkategorie ist erforderlich und muss unter 80 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (description.Length > 400)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Beschreibung muss unter 400 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (request.SortOrder is < 1 or > 200)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Die Reihenfolge muss zwischen 1 und 200 liegen." });
|
||||
}
|
||||
|
||||
if (request.MaxNomineesPerUser is < 1 or > 10)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das Nominierungs-Limit muss zwischen 1 und 10 liegen." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record BlockedSubcategoryRemoval(
|
||||
string GroupName,
|
||||
string SubcategoryName,
|
||||
string Slug,
|
||||
int CandidateCount,
|
||||
int NominationCount);
|
||||
|
||||
private static string BuildCategorySlug(string groupName, string templateSlug)
|
||||
{
|
||||
var groupSlug = SeasonSubcategoryTemplateSettings.Slugify(groupName);
|
||||
var detailSlug = SeasonSubcategoryTemplateSettings.Slugify(templateSlug);
|
||||
return string.IsNullOrWhiteSpace(groupSlug) ? detailSlug : $"{groupSlug}-{detailSlug}";
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
}
|
||||
|
||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings));
|
||||
|
||||
var season = new Season
|
||||
{
|
||||
@@ -45,6 +49,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
ReviewEndsAt = request.ReviewEndsAt,
|
||||
ShowDate = request.ShowDate,
|
||||
ShowStartsAt = request.ShowStartsAt,
|
||||
SubcategoryTemplatesJson = "[]",
|
||||
WorkflowRulesJson = initialWorkflowRulesJson,
|
||||
};
|
||||
|
||||
db.Seasons.Add(season);
|
||||
@@ -72,6 +78,13 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
copiedCategoryCount = sourceCategories.Length;
|
||||
var sourceSeason = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||
season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson;
|
||||
season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson)
|
||||
? initialWorkflowRulesJson
|
||||
: sourceSeason.WorkflowRulesJson;
|
||||
foreach (var category in sourceCategories)
|
||||
{
|
||||
db.Categories.Add(new Category
|
||||
@@ -83,6 +96,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = category.Description,
|
||||
SortOrder = category.SortOrder,
|
||||
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||
|
||||
var candidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new AdminCandidateItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.Platform,
|
||||
item.NominationTally,
|
||||
item.AcceptanceStatus,
|
||||
item.AcceptanceNote,
|
||||
item.ClipCompilationUrl,
|
||||
@@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
|
||||
season,
|
||||
categoryRows.Select(category => new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
}));
|
||||
var visibleCategoryRows = categoryRows
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
|
||||
new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
},
|
||||
subcategoryTemplateSettings))
|
||||
.ToArray();
|
||||
|
||||
var categories = categoryRows
|
||||
var categories = visibleCategoryRows
|
||||
.Select(category => new AdminCategoryItemDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
@@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominations = await db.Nominations
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||
|
||||
var pendingNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? string.Empty,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = await db.Nominations
|
||||
var pendingNominations = pendingNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
|
||||
|
||||
var reviewedNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = reviewedNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var resultItems = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CandidateId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.Platform))
|
||||
@@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
season.ReviewEndsAt,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
subcategoryTemplates,
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations,
|
||||
pendingNominationGroups,
|
||||
reviewedNominations,
|
||||
settings?.TrackingReviewNotes ?? string.Empty,
|
||||
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||
resultItems,
|
||||
clipSubmissions));
|
||||
}
|
||||
|
||||
private sealed record AdminNominationRow(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string? CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? HoursStreamed,
|
||||
int? HoursWatched,
|
||||
int? PeakViewers,
|
||||
int? FollowersGained,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
string TrackingFlagsJson,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
string? CandidateDisplayName,
|
||||
string? ReviewNote,
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
private static AdminNominationReviewItemDto ToNominationReviewItem(
|
||||
AdminNominationRow item,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
|
||||
.Select(ToTrackingFlagHitDto)
|
||||
.ToArray();
|
||||
|
||||
return new(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
ResolveCategoryGroupName(item, categoryRows),
|
||||
ResolveCategoryName(item, categoryRows),
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryName,
|
||||
item.StreamerIdentityId,
|
||||
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
|
||||
trackingFlags.Any(flag => flag.RequiresManualReview),
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(item, trackingRules),
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
item.CandidateDisplayName,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt);
|
||||
}
|
||||
|
||||
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
|
||||
IEnumerable<AdminNominationRow> rows,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
rows
|
||||
.GroupBy(item => new
|
||||
{
|
||||
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
|
||||
IdentityKey = item.StreamerIdentityId.HasValue
|
||||
? $"identity:{item.StreamerIdentityId.Value}"
|
||||
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
|
||||
})
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
|
||||
var representative = ordered
|
||||
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
|
||||
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
|
||||
.ThenByDescending(item => item.AvgViewers.HasValue)
|
||||
.First();
|
||||
var trackerStatus = ResolveGroupTrackerStatus(ordered);
|
||||
var trackingFlags = ordered
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.GroupBy(item => item.Key)
|
||||
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
|
||||
.ToArray();
|
||||
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
|
||||
return new AdminNominationReviewGroupDto(
|
||||
representative.Id,
|
||||
ordered.Select(item => item.Id).ToArray(),
|
||||
ResolveCategoryGroupName(representative, categoryRows),
|
||||
ResolveNominationDisplayName(representative),
|
||||
representative.StreamUrl,
|
||||
representative.ResolvedChannel,
|
||||
representative.ResolvedPlatform,
|
||||
representative.AvgViewers,
|
||||
representative.SuggestedCategoryId,
|
||||
representative.SuggestedCategoryName,
|
||||
representative.StreamerIdentityId,
|
||||
trackerStatus,
|
||||
representative.TrackerCheckedAt,
|
||||
ResolveGroupTrackingReviewStatus(ordered),
|
||||
requiresManualReview,
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(representative, trackingRules),
|
||||
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
|
||||
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
|
||||
ordered.Max(item => item.TrackingReviewedAt),
|
||||
ordered.Length,
|
||||
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
|
||||
ordered.First().CreatedAt,
|
||||
ordered.Last().CreatedAt);
|
||||
})
|
||||
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
|
||||
.ThenByDescending(item => item.RequiresManualReview)
|
||||
.ThenByDescending(item => item.NominationTally)
|
||||
.ThenByDescending(item => item.UniqueSubmitterCount)
|
||||
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
|
||||
.ThenByDescending(item => item.AvgViewers ?? -1)
|
||||
.ThenByDescending(item => item.LastSubmittedAt)
|
||||
.ToArray();
|
||||
|
||||
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
|
||||
item.ResolvedChannel
|
||||
?? item.CandidateText
|
||||
?? item.StreamUrl
|
||||
?? "Name im Review festlegen";
|
||||
|
||||
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackerStatus
|
||||
?? "pending";
|
||||
}
|
||||
|
||||
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackingReviewStatus
|
||||
?? "clear";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
|
||||
{
|
||||
return item.CategoryGroupName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.GroupName ?? "Unbekannte Hauptkategorie";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryName))
|
||||
{
|
||||
return item.CategoryName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
|
||||
}
|
||||
|
||||
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
|
||||
new(
|
||||
flag.Key,
|
||||
flag.Label,
|
||||
flag.Severity,
|
||||
flag.Description,
|
||||
flag.RequiresManualReview,
|
||||
flag.BlocksApproval,
|
||||
flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
|
||||
AdminNominationRow row,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
trackingRules.ImportantMetrics
|
||||
.Concat(trackingRules.OptionalMetrics)
|
||||
.Where(metric => metric.Enabled && metric.ShowInReview)
|
||||
.Select(metric => new AdminTrackingMetricStateDto(
|
||||
metric.Key,
|
||||
metric.Label,
|
||||
metric.RequiredForAutoClassification,
|
||||
metric.SourceSupport,
|
||||
MetricPresent(metric, row),
|
||||
MetricValue(metric, row),
|
||||
metric.Description,
|
||||
metric.WindowKey,
|
||||
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||
.ToArray();
|
||||
|
||||
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
|
||||
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.FollowersGained => row.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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategory")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminSeasonSubcategoryTemplates")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("CreateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||
.WithName("CreateAdminCandidate")
|
||||
@@ -56,11 +72,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("DeleteAdminResult")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/workflow-rules", GetWorkflowRules)
|
||||
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/workflow-rules", UpdateWorkflowRules)
|
||||
group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
|
||||
@@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
private sealed record CandidateRuleSnapshot(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string AcceptanceStatus);
|
||||
|
||||
private sealed record CandidateReadinessSnapshot(
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string AcceptanceStatus,
|
||||
string? ClipCompilationUrl);
|
||||
|
||||
private sealed record WinnerReadinessSnapshot(
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string? ClipCompilationUrl);
|
||||
|
||||
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||
{
|
||||
if (request.Year < 2020 || request.Year > 2100)
|
||||
@@ -145,13 +161,16 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
return WorkflowRuleSettings.Read(settings);
|
||||
return WorkflowRuleSettings.Read(season, settings);
|
||||
}
|
||||
|
||||
private static IResult CreateWorkflowRuleError(string message) =>
|
||||
@@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
int? streamerIdentityId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
@@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
|
||||
var rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
@@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new CandidateRuleSnapshot(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus))
|
||||
@@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
@@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
bool isCurrent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
if (season is null)
|
||||
{
|
||||
return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."];
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||
@@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return [];
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
|
||||
var categoryIds = await db.Categories
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
var categoriesWithCandidates = await db.Candidates
|
||||
var candidateSnapshots = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new CandidateReadinessSnapshot(
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus,
|
||||
item.ClipCompilationUrl))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var activeCandidates = candidateSnapshots
|
||||
.Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var categoriesWithCandidates = activeCandidates
|
||||
.Select(item => item.CategoryId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
.Count();
|
||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||
if (emptyCategories > 0)
|
||||
{
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten.");
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
var identityOverflow = activeCandidates
|
||||
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||
})
|
||||
.Where(item => item.Count > appearancesRule.Limit)
|
||||
.OrderByDescending(item => item.Count)
|
||||
.FirstOrDefault();
|
||||
if (identityOverflow is not null)
|
||||
{
|
||||
issues.Add(
|
||||
$"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWinnerReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now))
|
||||
{
|
||||
issues.Add("Die Award Show liegt noch nicht in der Vergangenheit.");
|
||||
}
|
||||
|
||||
var categoriesWithResults = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||
}
|
||||
|
||||
var resultSnapshots = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new WinnerReadinessSnapshot(
|
||||
item.CategoryId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.ClipCompilationUrl))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
||||
{
|
||||
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
||||
if (missingWinnerClipCount > 0)
|
||||
{
|
||||
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
||||
}
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||
{
|
||||
var winnerOverflow = resultSnapshots
|
||||
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||
})
|
||||
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
||||
.OrderByDescending(item => item.Count)
|
||||
.FirstOrDefault();
|
||||
if (winnerOverflow is not null)
|
||||
{
|
||||
issues.Add(
|
||||
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug)
|
||||
{
|
||||
if (streamerIdentityId.HasValue)
|
||||
{
|
||||
return $"identity:{streamerIdentityId.Value}";
|
||||
}
|
||||
|
||||
return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
}
|
||||
|
||||
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
||||
{
|
||||
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
||||
@@ -364,6 +484,21 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMin is < 0 or > 100000)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMax is < 0 or > 100000)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
||||
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
||||
@@ -56,11 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
var existingWinnerCount = existingWinnerIdentities.Count(item =>
|
||||
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
|
||||
||
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
|
||||
|
||||
if (existingWinnerCount >= winnerPlacementsRule.Limit)
|
||||
|
||||
@@ -41,6 +41,22 @@ public static class AdminSiteSettingsEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOptionalFeatureSettings")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/tracking-rules", GetTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules", UpdateTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/source", UpdateTrackingSource)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingSource")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingReviewNotes")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName,
|
||||
settings.HostTagline,
|
||||
settings.NewsletterUrl,
|
||||
settings.ShareXUrl,
|
||||
settings.ShareDiscordUrl,
|
||||
settings.PrivacyEmail,
|
||||
settings.PrivacyPolicyContent,
|
||||
settings.PrivacyPolicyUpdatedBy,
|
||||
@@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ShowactsUrl,
|
||||
settings.ShowactsContent,
|
||||
SeasonMappings.ReadSocialLinks(settings),
|
||||
SeasonMappings.ReadFaqItems(settings)));
|
||||
SeasonMappings.ReadFaqItems(settings),
|
||||
settings.ShowactFormSchemaJson ?? "[]"));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSiteSettings(
|
||||
@@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||
settings.HostTagline = request.HostTagline.Trim();
|
||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
||||
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||
@@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ShowactsContent = request.ShowactsContent.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
socialLinks = [];
|
||||
|
||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|
||||
@@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
normalizedUrls = new PublicSiteUrlSettings
|
||||
{
|
||||
NewsletterUrl = newsletterUrl,
|
||||
ShareXUrl = shareXUrl,
|
||||
ShareDiscordUrl = shareDiscordUrl,
|
||||
ImprintUrl = imprintUrl,
|
||||
ContactUrl = contactUrl,
|
||||
SponsorsUrl = sponsorsUrl,
|
||||
@@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
private sealed class PublicSiteUrlSettings
|
||||
{
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ShareXUrl { get; set; } = string.Empty;
|
||||
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||
public string ImprintUrl { get; set; } = string.Empty;
|
||||
public string ContactUrl { get; set; } = string.Empty;
|
||||
public string SponsorsUrl { get; set; } = string.Empty;
|
||||
@@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTrackingRules(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToTrackingRulesResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingRules(
|
||||
HttpContext context,
|
||||
UpdateTrackingRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var applyResult = ApplyTrackingRulesRequest(settings, request);
|
||||
if (applyResult is not null)
|
||||
{
|
||||
return applyResult;
|
||||
}
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Rules wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl,
|
||||
manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes,
|
||||
importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled),
|
||||
optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled),
|
||||
flagCount = after.Flags.Count(item => item.Enabled),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingSource(
|
||||
HttpContext context,
|
||||
UpdateTrackingSourceRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
var updatedRules = rules with
|
||||
{
|
||||
Source = new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? rules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview),
|
||||
};
|
||||
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules);
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-source.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Source wurde aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeBaseUrl = before.Source.BaseUrl,
|
||||
afterBaseUrl = after.Source.BaseUrl,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingReviewNotes(
|
||||
HttpContext context,
|
||||
UpdateTrackingReviewNotesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var beforeNotes = settings.TrackingReviewNotes ?? string.Empty;
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with
|
||||
{
|
||||
Source = rules.Source with
|
||||
{
|
||||
ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview,
|
||||
},
|
||||
});
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-review-notes.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Review Notes wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeLength = beforeNotes.Length,
|
||||
afterLength = settings.TrackingReviewNotes.Length,
|
||||
beforeShowInReview = before.Source.ShowManualReviewNotesInReview,
|
||||
afterShowInReview = after.Source.ShowManualReviewNotesInReview,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
||||
HttpContext context,
|
||||
UpdateOptionalFeatureSettingsRequest request,
|
||||
@@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
}
|
||||
|
||||
var before = ToOptionalFeatureSettingsResponse(settings);
|
||||
var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt);
|
||||
if (scheduleValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = scheduleValidationError });
|
||||
}
|
||||
|
||||
var disabledMessage = NormalizeOptionalFeatureText(
|
||||
request.ClipSubmissionDisabledMessage,
|
||||
FallbackClipSubmissionDisabledMessage,
|
||||
@@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
||||
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
||||
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
||||
settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt;
|
||||
settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt;
|
||||
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
||||
settings.SponsorsVisible = request.SponsorsVisible;
|
||||
|
||||
@@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints
|
||||
? FallbackClipSubmissionDisabledMessage
|
||||
: settings.ClipSubmissionDisabledMessage,
|
||||
settings.ShowactApplicationsEnabled,
|
||||
settings.ShowactApplicationStartsAt,
|
||||
settings.ShowactApplicationEndsAt,
|
||||
ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: settings.ShowactApplicationDisabledMessage,
|
||||
@@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
||||
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
||||
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
||||
AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt);
|
||||
AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt);
|
||||
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
||||
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
||||
return changes.ToArray();
|
||||
@@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||
usesDatabaseDemo,
|
||||
@@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
twitchSettings.ClientSecretSet,
|
||||
twitchSettings.RedirectUri,
|
||||
twitchSettings.Scope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||
@@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
||||
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
||||
var twitchScope = request.TwitchScope.Trim();
|
||||
if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." });
|
||||
}
|
||||
|
||||
var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours);
|
||||
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||
|
||||
@@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.TwitchClientSecret,
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope);
|
||||
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
|
||||
|
||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||
? newPassword
|
||||
@@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
"operational-settings.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
|
||||
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
settings.DemoLoginEnabled,
|
||||
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
||||
settings.SessionIdleTimeoutHours,
|
||||
settings.MaintenanceModeEnabled,
|
||||
changes,
|
||||
},
|
||||
@@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||
@@ -576,6 +781,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
||||
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
||||
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
||||
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
|
||||
|
||||
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
||||
{
|
||||
@@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
@@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints
|
||||
string RedirectUri,
|
||||
string Scope,
|
||||
bool Configured);
|
||||
|
||||
private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request)
|
||||
{
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var currentRules = TrackingRulesSettings.Read(settings);
|
||||
var configuration = new TrackingRulesConfiguration(
|
||||
new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? currentRules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview),
|
||||
(request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray());
|
||||
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration);
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings)
|
||||
{
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
return new AdminTrackingRulesResponse(
|
||||
new AdminTrackingSourceDto(
|
||||
rules.Source.ProviderKey,
|
||||
"TwitchTracker Basic API",
|
||||
TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl),
|
||||
rules.Source.NotesSummary,
|
||||
rules.Source.ShowManualReviewNotesInReview),
|
||||
rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(),
|
||||
settings.TrackingReviewNotes ?? string.Empty);
|
||||
}
|
||||
|
||||
private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
rule.WindowKey,
|
||||
rule.AutoSupportedWindowKeys,
|
||||
rule.ProviderFieldKey,
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
rule.IgnoredCategories,
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d),
|
||||
(rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(),
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
(rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage)
|
||||
{
|
||||
normalizedValue = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
var trimmed = (rawValue ?? string.Empty).Trim();
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,33 +8,41 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
|
||||
private static async Task<IResult> GetWorkflowRules(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(season, settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWorkflowRules(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateWorkflowRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = WorkflowRuleSettings.Read(settings);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var before = WorkflowRuleSettings.Read(season, settings);
|
||||
var mergedRules = WorkflowRuleSettings.Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
@@ -51,8 +59,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(settings);
|
||||
season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(season, settings);
|
||||
var changes = after
|
||||
.Select(rule =>
|
||||
{
|
||||
@@ -72,10 +80,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"workflow-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Workflow-Regeln wurden aktualisiert.",
|
||||
new { changes },
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.",
|
||||
new { seasonId = season.Id, season.Year, changes },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
|
||||
@@ -12,6 +12,7 @@ public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> DemoLogin(
|
||||
HttpContext context,
|
||||
IHostEnvironment environment,
|
||||
AwardsDbContext db,
|
||||
IConfiguration configuration,
|
||||
DemoLoginRequest request,
|
||||
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
|
||||
var password = request.Password ?? string.Empty;
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var databaseDemoConfigured = settings is not null
|
||||
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
|
||||
&& settings.DemoLoginManagedByDatabase;
|
||||
|
||||
string twitchUserId;
|
||||
string displayName;
|
||||
bool credentialsMatch;
|
||||
var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (databaseDemoConfigured && settings is not null)
|
||||
{
|
||||
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
|
||||
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||
displayName = settings.DemoLoginDisplayName.Trim();
|
||||
|
||||
if (!credentialsMatch
|
||||
&& environment.IsDevelopment()
|
||||
&& IsDemoLoginEnabled(configuration)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var configuredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuredLogin)
|
||||
|| string.IsNullOrWhiteSpace(configuredPassword)
|
||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(displayName))
|
||||
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Demo login is not fully configured." },
|
||||
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
|
||||
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
configuredLogin,
|
||||
configuredEmail,
|
||||
twitchUserId,
|
||||
displayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
|
||||
twitchUserId = twitchUserId.Trim();
|
||||
displayName = displayName.Trim();
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
|
||||
if (!credentialsMatch)
|
||||
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
|
||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||
@@ -36,6 +36,7 @@ public static partial class AuthEndpoints
|
||||
|
||||
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
UserSession session,
|
||||
bool mustChangePassword = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
|
||||
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||
var sessionRole = teamMember?.Role ?? session.Role;
|
||||
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
|
||||
return new(
|
||||
session.SessionToken,
|
||||
session.TwitchUserId,
|
||||
teamMember?.DisplayName ?? session.DisplayName,
|
||||
AdminRoles.Normalize(sessionRole),
|
||||
permissionKeys,
|
||||
sessionIdleTimeoutHours,
|
||||
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||
teamMember?.Login,
|
||||
teamMember?.BoundTwitchUserId,
|
||||
|
||||
@@ -44,7 +44,7 @@ public static partial class AuthEndpoints
|
||||
context.RequestAborted);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(
|
||||
@@ -92,7 +92,7 @@ public static partial class AuthEndpoints
|
||||
session.Role = AdminRoles.Normalize(member.Role);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static string BuildTeamSessionId(string login) =>
|
||||
|
||||
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||
false,
|
||||
false,
|
||||
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
|
||||
currentSessionUsesBoundTwitch,
|
||||
currentSessionUsesBoundTwitch
|
||||
? null
|
||||
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
@@ -8,6 +11,8 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
|
||||
|
||||
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
@@ -45,13 +50,19 @@ public static partial class PublicEndpoints
|
||||
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private static async Task<IResult> CreateShowactApplication(
|
||||
HttpContext context,
|
||||
CreateShowactApplicationRequest request,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (settings is null || !settings.ShowactApplicationsEnabled)
|
||||
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
@@ -67,55 +78,137 @@ public static partial class PublicEndpoints
|
||||
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||
}
|
||||
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
||||
var hasDynamicForm = schema.Count > 0;
|
||||
|
||||
if (hasDynamicForm)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
Dictionary<string, string> responses;
|
||||
try
|
||||
{
|
||||
responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson)
|
||||
? new Dictionary<string, string>()
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(request.FieldResponsesJson, ShowactJsonOptions) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
MergeLegacyShowactFieldsIntoResponses(schema, responses, request);
|
||||
|
||||
var dynamicValidationError = ValidateShowactResponses(schema, responses);
|
||||
if (dynamicValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = dynamicValidationError });
|
||||
}
|
||||
|
||||
var artistNameField = schema.FirstOrDefault(f => f.IsArtistName);
|
||||
var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt";
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions);
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName[..Math.Min(artistName.Length, 120)],
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = technicalNotes,
|
||||
ReferenceUrl = referenceUrl,
|
||||
FieldResponsesJson = fieldResponsesJson,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
// Legacy fixed-fields path
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrValidEmail(contactEmail))
|
||||
{
|
||||
return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
private sealed class ShowactFieldDefinition
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||
[JsonPropertyName("required")] public bool Required { get; set; }
|
||||
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
|
||||
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
|
||||
}
|
||||
|
||||
private static string NormalizePublicText(string? value, int maxLength)
|
||||
@@ -128,4 +221,167 @@ public static partial class PublicEndpoints
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||
|
||||
private static bool IsBlankOrValidEmail(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
|
||||
|
||||
private static bool IsBlankOrValidJsonObject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(value);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Object;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateShowactResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses)
|
||||
{
|
||||
foreach (var field in schema)
|
||||
{
|
||||
var value = responses.TryGetValue(field.Id, out var rawValue)
|
||||
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
|
||||
: string.Empty;
|
||||
responses[field.Id] = value;
|
||||
|
||||
if (field.Required && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
|
||||
? $"Bitte bestaetige: {field.Label}"
|
||||
: $"{field.Label} ist erforderlich.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
|
||||
{
|
||||
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
|
||||
{
|
||||
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
|
||||
{
|
||||
if (field.MaxLength > 0)
|
||||
{
|
||||
return Math.Min(field.MaxLength, 2000);
|
||||
}
|
||||
|
||||
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
|
||||
}
|
||||
|
||||
private static void MergeLegacyShowactFieldsIntoResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses,
|
||||
CreateShowactApplicationRequest request)
|
||||
{
|
||||
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
|
||||
MergeResponseValue(artistField, request.ArtistName, responses, 120);
|
||||
|
||||
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
|
||||
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
|
||||
|
||||
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
|
||||
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
|
||||
|
||||
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
|
||||
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
|
||||
|
||||
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
|
||||
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
|
||||
|
||||
var performanceField = schema.FirstOrDefault(field =>
|
||||
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|
||||
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
|
||||
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
|
||||
|
||||
var descriptionField = schema.FirstOrDefault(field =>
|
||||
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
|
||||
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|
||||
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
|
||||
MergeResponseValue(descriptionField, request.Description, responses, 1000);
|
||||
|
||||
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
|
||||
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
|
||||
}
|
||||
|
||||
private static void MergeResponseValue(
|
||||
ShowactFieldDefinition? field,
|
||||
string? requestValue,
|
||||
IDictionary<string, string> responses,
|
||||
int maxLength)
|
||||
{
|
||||
if (field is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = NormalizePublicText(requestValue, maxLength);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
responses[field.Id] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Id.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Label.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,14 @@ public static partial class PublicEndpoints
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
IRiskRuleService riskRuleService,
|
||||
NominationEnrichmentService nominationEnrichmentService)
|
||||
{
|
||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
||||
|
||||
if (submittedNominations.Length is 0 or > 3)
|
||||
if (submittedNominations.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
|
||||
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
||||
@@ -35,7 +36,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var distinctStreamUrls = submittedNominations
|
||||
.Select(item => item.StreamUrl)
|
||||
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
@@ -65,16 +66,37 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
||||
}
|
||||
|
||||
var category = await db.Categories
|
||||
.Include(item => item.Season)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||
|
||||
if (category is null)
|
||||
if (season is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||
return Results.BadRequest(new { message = "The selected season does not exist." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
|
||||
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted);
|
||||
if (string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var groupCategories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories);
|
||||
if (submittedNominations.Length > maxNomineesPerUser)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||
if (nominationSeasonResolution.Result is not null)
|
||||
{
|
||||
return nominationSeasonResolution.Result;
|
||||
@@ -89,15 +111,16 @@ public static partial class PublicEndpoints
|
||||
var submitterId = submitterIdResult.SubmitterId!;
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
item.SeasonId == season.Id
|
||||
&& item.CategoryGroupName == categoryGroupName
|
||||
&& item.SubmittedByTwitchId == submitterId
|
||||
&& item.Status == "pending");
|
||||
|
||||
var records = submittedNominations.Select(nomination => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
SeasonId = season.Id,
|
||||
CategoryId = null,
|
||||
CategoryGroupName = categoryGroupName,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||
@@ -106,6 +129,11 @@ public static partial class PublicEndpoints
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}).ToArray();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||
@@ -126,21 +154,21 @@ public static partial class PublicEndpoints
|
||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
resubmittedNominationRule.Severity,
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
||||
requestMetadata,
|
||||
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
@@ -152,7 +180,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
||||
return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 });
|
||||
}
|
||||
|
||||
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||
@@ -191,4 +219,44 @@ public static partial class PublicEndpoints
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeNominationUrlForCompare(string value) =>
|
||||
value.Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
|
||||
{
|
||||
var configuredLimit = groupCategories
|
||||
.Select(item => item.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return Math.Clamp(configuredLimit, 1, 10);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveCategoryGroupNameAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
CreateNominationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var categoryGroupName = request.CategoryGroupName?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!request.CategoryId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,18 @@ public static partial class PublicEndpoints
|
||||
{
|
||||
return Results.Problem("Site settings are missing.");
|
||||
}
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
|
||||
|
||||
var canExposeCurrentSeasonWinners = CanExposeCurrentSeasonWinners(season.CurrentPhase);
|
||||
|
||||
var winnerPreviewRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Season)
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result =>
|
||||
result.Season.Year < season.Year
|
||||
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||
.OrderByDescending(result => result.Season.Year)
|
||||
.ThenBy(result => result.CategoryName)
|
||||
.Take(8)
|
||||
@@ -65,7 +71,9 @@ public static partial class PublicEndpoints
|
||||
|
||||
var archiveYearRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result =>
|
||||
result.Season.Year < season.Year
|
||||
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||
.GroupBy(result => result.Season.Year)
|
||||
.Select(group => new
|
||||
{
|
||||
@@ -83,6 +91,33 @@ public static partial class PublicEndpoints
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var featuredCategories = publicCategories
|
||||
.GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group
|
||||
.OrderBy(category => category.SortOrder)
|
||||
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var first = ordered[0];
|
||||
var maxNomineesPerUser = ordered
|
||||
.Select(category => category.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return new FeaturedCategoryDto(
|
||||
first.Id,
|
||||
first.GroupName,
|
||||
first.GroupName,
|
||||
first.Description,
|
||||
maxNomineesPerUser);
|
||||
})
|
||||
.OrderBy(category => publicCategories
|
||||
.Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase))
|
||||
.Min(item => item.SortOrder))
|
||||
.ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var response = new OverviewResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
@@ -100,20 +135,15 @@ public static partial class PublicEndpoints
|
||||
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
||||
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||
},
|
||||
publicCategories
|
||||
.Select(category => new FeaturedCategoryDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser))
|
||||
.ToArray(),
|
||||
featuredCategories,
|
||||
winnerPreviewItems,
|
||||
archiveYears,
|
||||
new PublicSiteContentDto(
|
||||
siteSettings.HostDisplayName,
|
||||
siteSettings.HostTagline,
|
||||
siteSettings.NewsletterUrl,
|
||||
siteSettings.ShareXUrl,
|
||||
siteSettings.ShareDiscordUrl,
|
||||
siteSettings.PrivacyEmail,
|
||||
siteSettings.PrivacyPolicyContent,
|
||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||
@@ -124,11 +154,14 @@ public static partial class PublicEndpoints
|
||||
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||
: siteSettings.ClipSubmissionDisabledMessage,
|
||||
siteSettings.ShowactApplicationsEnabled,
|
||||
showactApplicationsOpenNow,
|
||||
siteSettings.ShowactApplicationStartsAt,
|
||||
siteSettings.ShowactApplicationEndsAt,
|
||||
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: siteSettings.ShowactApplicationDisabledMessage,
|
||||
siteSettings.SponsorsVisible),
|
||||
siteSettings.SponsorsVisible,
|
||||
siteSettings.ShowactFormSchemaJson ?? "[]"),
|
||||
SeasonMappings.ReadFaqItems(siteSettings));
|
||||
|
||||
return Results.Ok(response);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Common;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||
@@ -54,6 +57,8 @@ public static partial class PublicEndpoints
|
||||
category.GroupName,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
category.Candidates
|
||||
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(candidate =>
|
||||
|
||||
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
|
||||
|
||||
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||
{
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
if (!usesDatabaseDemo)
|
||||
{
|
||||
return IsDemoLoginEnabled(configuration);
|
||||
|
||||
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.CategoryGroupName,
|
||||
item.Status,
|
||||
Nominee = item.CandidateId != null
|
||||
? item.Candidate!.DisplayName
|
||||
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
|
||||
var groupedNominations = nominations
|
||||
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
|
||||
.Select(group => new UserNominationStateDto(
|
||||
group.Key,
|
||||
group.Key.CategoryId,
|
||||
group.Key.CategoryGroupName,
|
||||
group.Select(item => item.Nominee!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()))
|
||||
|
||||
@@ -26,6 +26,11 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("TwitchTracker", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(4);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
|
||||
});
|
||||
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
||||
|
||||
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
||||
@@ -88,6 +93,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
||||
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
||||
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
||||
services.AddScoped<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
|
||||
services.AddScoped<NominationTrackingReviewService>();
|
||||
services.AddScoped<NominationEnrichmentService>();
|
||||
services.AddScoped<AdminSessionFilter>();
|
||||
|
||||
return services;
|
||||
|
||||
+1768
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShareUrls : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShareDiscordUrl",
|
||||
table: "SiteSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShareXUrl",
|
||||
table: "SiteSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "ShareDiscordUrl", "ShareXUrl" },
|
||||
values: new object[] { "", "" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShareDiscordUrl",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShareXUrl",
|
||||
table: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShowactDynamicForm : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShowactFormSchemaJson",
|
||||
table: "SiteSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FieldResponsesJson",
|
||||
table: "ShowactApplications",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "ShowactFormSchemaJson",
|
||||
value: "[]");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowactFormSchemaJson",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FieldResponsesJson",
|
||||
table: "ShowactApplications");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCategoryViewerRanges : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ViewerRangeMax",
|
||||
table: "Categories",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ViewerRangeMin",
|
||||
table: "Categories",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 5,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 6,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 7,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 8,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 9,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 10,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ViewerRangeMax",
|
||||
table: "Categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ViewerRangeMin",
|
||||
table: "Categories");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1789
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSessionIdleTimeoutSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SessionIdleTimeoutHours",
|
||||
table: "SiteSettings",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 3);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "SessionIdleTimeoutHours",
|
||||
value: 3);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SessionIdleTimeoutHours",
|
||||
table: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1799
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeasonSubcategoryTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SubcategoryTemplatesJson",
|
||||
table: "Seasons",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubcategoryTemplatesJson",
|
||||
table: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1936
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNominationGroupTrackerIdentity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "CategoryId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "integer");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgViewers",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CategoryGroupName",
|
||||
table: "Nominations",
|
||||
type: "character varying(80)",
|
||||
maxLength: 80,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResolvedChannel",
|
||||
table: "Nominations",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResolvedPlatform",
|
||||
table: "Nominations",
|
||||
type: "character varying(40)",
|
||||
maxLength: 40,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SuggestedCategoryId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "TrackerCheckedAt",
|
||||
table: "Nominations",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TrackerStatus",
|
||||
table: "Nominations",
|
||||
type: "character varying(40)",
|
||||
maxLength: 40,
|
||||
nullable: false,
|
||||
defaultValue: "pending");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "NominationTally",
|
||||
table: "Candidates",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Candidates",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StreamerIdentities",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Platform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
Login = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
NormalizedKey = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ProfileUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
LastResolvedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StreamerIdentities", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 5,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 6,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 7,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 8,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 9,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 10,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 11,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 12,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 13,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Nominations",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" },
|
||||
values: new object[] { null, "", null, null, null, null, null, "pending" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Nominations",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" },
|
||||
values: new object[] { null, "", null, null, null, null, null, "pending" });
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "Nominations" n
|
||||
SET "CategoryGroupName" = c."GroupName"
|
||||
FROM "Categories" c
|
||||
WHERE n."CategoryId" = c."Id"
|
||||
AND COALESCE(n."CategoryGroupName", '') = '';
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_CategoryGroupName_Status",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "CategoryGroupName", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "StreamerIdentityId", "CategoryGroupName" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_StreamerIdentityId",
|
||||
table: "Nominations",
|
||||
column: "StreamerIdentityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SuggestedCategoryId",
|
||||
table: "Nominations",
|
||||
column: "SuggestedCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Candidates_StreamerIdentityId",
|
||||
table: "Candidates",
|
||||
column: "StreamerIdentityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StreamerIdentities_NormalizedKey",
|
||||
table: "StreamerIdentities",
|
||||
column: "NormalizedKey",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Candidates_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Candidates",
|
||||
column: "StreamerIdentityId",
|
||||
principalTable: "StreamerIdentities",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations",
|
||||
column: "CategoryId",
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_Categories_SuggestedCategoryId",
|
||||
table: "Nominations",
|
||||
column: "SuggestedCategoryId",
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Nominations",
|
||||
column: "StreamerIdentityId",
|
||||
principalTable: "StreamerIdentities",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Candidates_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_Categories_SuggestedCategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StreamerIdentities");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SeasonId_CategoryGroupName_Status",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_StreamerIdentityId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SuggestedCategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Candidates_StreamerIdentityId",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgViewers",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CategoryGroupName",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResolvedChannel",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResolvedPlatform",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SuggestedCategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TrackerCheckedAt",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TrackerStatus",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NominationTally",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "CategoryId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "integer",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations",
|
||||
column: "CategoryId",
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1942
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShowactApplicationSchedule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "ShowactApplicationEndsAt",
|
||||
table: "SiteSettings",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "ShowactApplicationStartsAt",
|
||||
table: "SiteSettings",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "ShowactApplicationEndsAt", "ShowactApplicationStartsAt" },
|
||||
values: new object[] { null, null });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowactApplicationEndsAt",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowactApplicationStartsAt",
|
||||
table: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeasonWorkflowRulesJson : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "WorkflowRulesJson",
|
||||
table: "Seasons",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "Seasons"
|
||||
SET "WorkflowRulesJson" = COALESCE(NULLIF((SELECT "WorkflowRulesJson" FROM "SiteSettings" WHERE "Id" = 1), ''), '[]')
|
||||
WHERE COALESCE("WorkflowRulesJson", '') = '';
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WorkflowRulesJson",
|
||||
table: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('-');
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user