Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season

Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

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