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( public sealed record AdminNominationReviewItemDto(
int Id, int Id,
int CategoryId, int? CategoryId,
string CategoryGroupName,
string CategoryName, string CategoryName,
string SubmittedByTwitchId, string SubmittedByTwitchId,
string CandidateText, string CandidateText,
string? StreamUrl, 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, string Status,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
int? CandidateId, int? CandidateId,
@@ -68,6 +84,32 @@ public sealed record AdminNominationReviewItemDto(
string? ReviewedByTwitchId, string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt); 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( public sealed record AdminClipSubmissionItemDto(
int Id, int Id,
int? CategoryId, int? CategoryId,
@@ -87,10 +129,17 @@ public sealed record ApproveNominationRequest(
string? DisplayName, string? DisplayName,
string? ChannelSlug, string? ChannelSlug,
string? Platform, string? Platform,
int? CategoryId,
string? ReviewNote); string? ReviewNote);
public sealed record RejectNominationRequest(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 AdminNominationLinkBlacklistEntryDto(string Url);
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries); public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
@@ -136,3 +185,24 @@ public sealed record AdminWorkflowRuleDto(
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules); public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
public sealed record UpdateWorkflowRulesRequest(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, string Description,
int SortOrder, int SortOrder,
int MaxNomineesPerUser, int MaxNomineesPerUser,
int? ViewerRangeMin,
int? ViewerRangeMax,
int CandidateCount); int CandidateCount);
public sealed record AdminSubcategoryTemplateDto(
string Name,
string Slug,
int SortOrder,
int? ViewerRangeMin,
int? ViewerRangeMax);
public sealed record AdminCandidateItemDto( public sealed record AdminCandidateItemDto(
int Id, int Id,
int CategoryId, int CategoryId,
int? StreamerIdentityId,
string DisplayName, string DisplayName,
string ChannelSlug, string ChannelSlug,
string Platform, string Platform,
int NominationTally,
string AcceptanceStatus, string AcceptanceStatus,
string? AcceptanceNote, string? AcceptanceNote,
string? ClipCompilationUrl, string? ClipCompilationUrl,
@@ -36,6 +47,7 @@ public sealed record AdminAwardResultItemDto(
int CategoryId, int CategoryId,
string CategoryName, string CategoryName,
int CandidateId, int CandidateId,
int? StreamerIdentityId,
string CandidateDisplayName, string CandidateDisplayName,
string CandidateChannelSlug, string CandidateChannelSlug,
string CandidatePlatform); string CandidatePlatform);
@@ -56,10 +68,14 @@ public sealed record AdminSeasonDetailResponse(
DateOnly ReviewEndsAt, DateOnly ReviewEndsAt,
DateOnly ShowDate, DateOnly ShowDate,
TimeOnly ShowStartsAt, TimeOnly ShowStartsAt,
IEnumerable<AdminSubcategoryTemplateDto> SubcategoryTemplates,
IEnumerable<AdminCategoryItemDto> Categories, IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates, IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations, IEnumerable<AdminNominationReviewItemDto> PendingNominations,
IEnumerable<AdminNominationReviewGroupDto> PendingNominationGroups,
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations, IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
string TrackingReviewNotes,
bool ShowTrackingReviewNotes,
IEnumerable<AdminAwardResultItemDto> Results, IEnumerable<AdminAwardResultItemDto> Results,
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions); IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
@@ -102,6 +118,17 @@ public sealed record UpsertCategoryRequest(
string Slug, string Slug,
string Description, string Description,
int SortOrder, 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); int MaxNomineesPerUser);
public sealed record UpsertCandidateRequest( public sealed record UpsertCandidateRequest(
@@ -4,6 +4,8 @@ public sealed record AdminSiteSettingsResponse(
string HostDisplayName, string HostDisplayName,
string HostTagline, string HostTagline,
string NewsletterUrl, string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
string PrivacyEmail, string PrivacyEmail,
string PrivacyPolicyContent, string PrivacyPolicyContent,
string? PrivacyPolicyUpdatedBy, string? PrivacyPolicyUpdatedBy,
@@ -17,12 +19,15 @@ public sealed record AdminSiteSettingsResponse(
string ShowactsUrl, string ShowactsUrl,
string ShowactsContent, string ShowactsContent,
IEnumerable<PublicSocialLinkDto> SocialLinks, IEnumerable<PublicSocialLinkDto> SocialLinks,
IEnumerable<FaqItemDto> Faq); IEnumerable<FaqItemDto> Faq,
string ShowactFormSchemaJson);
public sealed record UpdateSiteSettingsRequest( public sealed record UpdateSiteSettingsRequest(
string HostDisplayName, string HostDisplayName,
string HostTagline, string HostTagline,
string NewsletterUrl, string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
string PrivacyEmail, string PrivacyEmail,
string PrivacyPolicyContent, string PrivacyPolicyContent,
string ImprintUrl, string ImprintUrl,
@@ -34,7 +39,8 @@ public sealed record UpdateSiteSettingsRequest(
string ShowactsUrl, string ShowactsUrl,
string ShowactsContent, string ShowactsContent,
PublicSocialLinkDto[] SocialLinks, PublicSocialLinkDto[] SocialLinks,
FaqItemDto[] Faq); FaqItemDto[] Faq,
string? ShowactFormSchemaJson = null);
public sealed record AdminOperationalSettingsResponse( public sealed record AdminOperationalSettingsResponse(
bool DemoLoginManagedByDatabase, bool DemoLoginManagedByDatabase,
@@ -49,6 +55,7 @@ public sealed record AdminOperationalSettingsResponse(
bool TwitchClientSecretSet, bool TwitchClientSecretSet,
string TwitchRedirectUri, string TwitchRedirectUri,
string TwitchScope, string TwitchScope,
int SessionIdleTimeoutHours,
bool MaintenanceModeEnabled, bool MaintenanceModeEnabled,
string MaintenanceTitle, string MaintenanceTitle,
string MaintenanceMessage); string MaintenanceMessage);
@@ -59,6 +66,9 @@ public sealed record AdminOptionalFeatureSettingsResponse(
bool ClipAdminMenuVisible, bool ClipAdminMenuVisible,
string ClipSubmissionDisabledMessage, string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled, bool ShowactApplicationsEnabled,
DateOnly? ShowactApplicationStartsAt,
DateOnly? ShowactApplicationEndsAt,
bool ShowactApplicationsOpenNow,
string ShowactApplicationDisabledMessage, string ShowactApplicationDisabledMessage,
bool SponsorsVisible); bool SponsorsVisible);
@@ -68,6 +78,8 @@ public sealed record UpdateOptionalFeatureSettingsRequest(
bool ClipAdminMenuVisible, bool ClipAdminMenuVisible,
string ClipSubmissionDisabledMessage, string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled, bool ShowactApplicationsEnabled,
DateOnly? ShowactApplicationStartsAt,
DateOnly? ShowactApplicationEndsAt,
string ShowactApplicationDisabledMessage, string ShowactApplicationDisabledMessage,
bool SponsorsVisible); bool SponsorsVisible);
@@ -81,6 +93,71 @@ public sealed record UpdateOperationalSettingsRequest(
string? TwitchClientSecret, string? TwitchClientSecret,
string TwitchRedirectUri, string TwitchRedirectUri,
string TwitchScope, string TwitchScope,
int SessionIdleTimeoutHours,
bool MaintenanceModeEnabled, bool MaintenanceModeEnabled,
string MaintenanceTitle, string MaintenanceTitle,
string MaintenanceMessage); 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 DisplayName,
string Role, string Role,
IEnumerable<string> PermissionKeys, IEnumerable<string> PermissionKeys,
int SessionIdleTimeoutHours,
bool MustChangePassword = false, bool MustChangePassword = false,
string? TeamLogin = null, string? TeamLogin = null,
string? BoundTwitchUserId = null, string? BoundTwitchUserId = null,
+11 -9
View File
@@ -36,16 +36,18 @@ public sealed record ShowactApplicationDto(
string Status, string Status,
string? ReviewNote, string? ReviewNote,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset? ReviewedAt); DateTimeOffset? ReviewedAt,
string FieldResponsesJson);
public sealed record CreateShowactApplicationRequest( public sealed record CreateShowactApplicationRequest(
string ArtistName, string? ArtistName = null,
string ContactEmail, string? ContactEmail = null,
string ContactDiscord, string? ContactDiscord = null,
string PlatformUrl, string? PlatformUrl = null,
string PerformanceType, string? PerformanceType = null,
string Description, string? Description = null,
string TechnicalNotes, string? TechnicalNotes = null,
string ReferenceUrl); string? ReferenceUrl = null,
string? FieldResponsesJson = null);
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote); public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
+6 -1
View File
@@ -50,6 +50,8 @@ public sealed record PublicSiteContentDto(
string HostDisplayName, string HostDisplayName,
string HostTagline, string HostTagline,
string NewsletterUrl, string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
string PrivacyEmail, string PrivacyEmail,
string PrivacyPolicyContent, string PrivacyPolicyContent,
IEnumerable<PublicSocialLinkDto> SocialLinks, IEnumerable<PublicSocialLinkDto> SocialLinks,
@@ -66,8 +68,11 @@ public sealed record PublicFeatureFlagsDto(
bool ClipReviewEnabled, bool ClipReviewEnabled,
string ClipSubmissionDisabledMessage, string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled, bool ShowactApplicationsEnabled,
DateOnly? ShowactApplicationStartsAt,
DateOnly? ShowactApplicationEndsAt,
string ShowactApplicationDisabledMessage, string ShowactApplicationDisabledMessage,
bool SponsorsVisible); bool SponsorsVisible,
string ShowactFormSchemaJson);
public sealed record OverviewResponse( public sealed record OverviewResponse(
int SeasonId, int SeasonId,
@@ -17,6 +17,8 @@ public sealed record PublicCategoryDetailDto(
string GroupName, string GroupName,
string Description, string Description,
int MaxNomineesPerUser, int MaxNomineesPerUser,
int? ViewerRangeMin,
int? ViewerRangeMax,
IEnumerable<CandidateSummaryDto> Candidates); IEnumerable<CandidateSummaryDto> Candidates);
public sealed record SeasonCategoriesResponse( public sealed record SeasonCategoriesResponse(
@@ -1,7 +1,8 @@
namespace Backend.Contracts; namespace Backend.Contracts;
public sealed record UserNominationStateDto( public sealed record UserNominationStateDto(
int CategoryId, int? CategoryId,
string CategoryGroupName,
string[] Nominees); string[] Nominees);
public sealed record UserVoteStateDto( public sealed record UserVoteStateDto(
+2 -1
View File
@@ -6,7 +6,8 @@ public sealed record NominationEntryRequest(
public sealed record CreateNominationRequest( public sealed record CreateNominationRequest(
int Year, int Year,
int CategoryId, int? CategoryId,
string? CategoryGroupName,
string TwitchUserId, string TwitchUserId,
string[]? Nominees, string[]? Nominees,
NominationEntryRequest[]? Nominations); NominationEntryRequest[]? Nominations);
+44
View File
@@ -8,6 +8,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<Season> Seasons => Set<Season>(); public DbSet<Season> Seasons => Set<Season>();
public DbSet<Category> Categories => Set<Category>(); public DbSet<Category> Categories => Set<Category>();
public DbSet<Candidate> Candidates => Set<Candidate>(); public DbSet<Candidate> Candidates => Set<Candidate>();
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
public DbSet<AwardResult> Results => Set<AwardResult>(); public DbSet<AwardResult> Results => Set<AwardResult>();
public DbSet<Nomination> Nominations => Set<Nomination>(); public DbSet<Nomination> Nominations => Set<Nomination>();
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>(); 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.Name).HasMaxLength(160);
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400); entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
entity.Property(item => item.CurrentPhase).HasMaxLength(60); entity.Property(item => item.CurrentPhase).HasMaxLength(60);
entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]");
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
}); });
modelBuilder.Entity<SiteSettings>(entity => 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.TwitchClientSecret).HasMaxLength(180);
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400); entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
entity.Property(item => item.TwitchScope).HasMaxLength(300); 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.MaintenanceTitle).HasMaxLength(120);
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600); entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]"); 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.NominationLinkBlacklistJson).HasDefaultValue("[]");
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false); entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true); entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240); entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false); 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.ShowactApplicationDisabledMessage).HasMaxLength(240);
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true); 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.GroupName).HasMaxLength(80);
entity.Property(item => item.Name).HasMaxLength(120); entity.Property(item => item.Name).HasMaxLength(120);
entity.Property(item => item.Description).HasMaxLength(400); entity.Property(item => item.Description).HasMaxLength(400);
entity.Property(item => item.ViewerRangeMin);
entity.Property(item => item.ViewerRangeMax);
}); });
modelBuilder.Entity<Candidate>(entity => modelBuilder.Entity<Candidate>(entity =>
{ {
entity.HasIndex(item => item.StreamerIdentityId);
entity.Property(item => item.DisplayName).HasMaxLength(120); entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.ChannelSlug).HasMaxLength(120); entity.Property(item => item.ChannelSlug).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40); 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.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
entity.Property(item => item.AcceptanceNote).HasMaxLength(500); entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
entity.Property(item => item.ClipCompilationUrl).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"); 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 => 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.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CandidateText).HasMaxLength(120); entity.Property(item => item.CandidateText).HasMaxLength(120);
entity.Property(item => item.StreamUrl).HasMaxLength(300); 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.Status).HasMaxLength(20);
entity.Property(item => item.ReviewNote).HasMaxLength(500); entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120); entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
entity.HasIndex(item => new { item.SeasonId, item.Status }); 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 => modelBuilder.Entity<VoteBallot>(entity =>
@@ -19,6 +19,15 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]'; 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" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]'; ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
@@ -34,12 +43,30 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false; 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" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.'; ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
ALTER TABLE "SiteSettings" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true; 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" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false; ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
@@ -55,6 +82,9 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT ''; 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" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT ''; ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '';
@@ -70,6 +100,9 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings" ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT ''; 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" ( CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL, "SeasonId" integer NULL,
@@ -254,6 +287,95 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "Candidates" ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked'; 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" ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending'; 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" CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
ON "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" ( CREATE TABLE IF NOT EXISTS "TeamMembers" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Login" character varying(80) NOT NULL, "Login" character varying(80) NOT NULL,
+123 -36
View File
@@ -1,4 +1,5 @@
using Backend.Domain; using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Backend.Data; namespace Backend.Data;
@@ -7,54 +8,137 @@ public static partial class SeedDataBootstrapper
{ {
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season) 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) .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 = award.Name;
category.GroupName = target.GroupName; category.Name = template.Name;
category.Name = target.Name; category.Slug = BuildCategorySlug(award.Slug, template.Slug);
category.Slug = target.Slug; category.Description = award.Description;
category.Description = target.Description; category.SortOrder = nextSortOrder++;
category.SortOrder = target.SortOrder; category.MaxNomineesPerUser = 3;
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(); await db.SaveChangesAsync();
}
var existing = await db.Categories private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
.Where(item => item.SeasonId == season.Id) {
.Select(item => item.Slug) if (staleCategories.Length == 0)
.ToArrayAsync();
var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var seed in SeedCatalog.CategorySeeds)
{ {
if (existingSlugs.Contains(seed.Slug)) return;
{
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,
});
} }
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) 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; 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 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 WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
internal sealed record SiteFaqSeed(string Question, string Answer); 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 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("Hidden Star", "hidden-star", 1, 1, 20),
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2), 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("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("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), 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."), "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( new(
"Wie funktioniert das Voting?", "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( new(
"Was kostet die Teilnahme?", "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( new(
"Wann und wo findet die Award-Show statt?", "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( new(
"Ich wurde nominiert — was nun?", "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 = internal static readonly SiteSocialSeed[] SiteSocialSeeds =
@@ -60,18 +69,18 @@ Anbieter
VTuber Star Awards, vertreten durch Jayuhime. VTuber Star Awards, vertreten durch Jayuhime.
Kontakt 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 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 = """ internal const string DefaultContactContent = """
Kontakt zum Award-Team 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 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 Community & Kooperationen
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert. 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 = """ internal const string DefaultSponsorsContent = """
Sponsoren & Partner 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 = internal static readonly CandidateSeed[] CurrentCandidateSeeds =
[ [
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"), new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"),
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"), new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"), new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"), new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"), new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"), new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"), new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"), new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
new("best-gaming", "Kurainu", "@kurainu", "Twitch"), new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"), new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"),
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"), new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"), new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"), new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
new("community-liebling", "Lumi", "@lumi_vt", "Cake"), new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"),
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"), new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"),
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"), new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"),
]; ];
internal static readonly WinnerSeed[] WinnerSeeds = internal static readonly WinnerSeed[] WinnerSeeds =
[ [
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"), new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"), new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"), new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"), new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"), new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"), new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"), new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"),
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"), new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"), new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"),
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"), new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"), new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"), new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"),
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"), new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"), new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"),
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"), new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"), new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"),
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"), new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"), new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"),
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"), new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"),
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"), new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"),
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"), new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"),
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"), new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"), 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", SponsorsUrl = "https://vtuber-star-awards.de/partner",
SponsorsContent = SeedCatalog.DefaultSponsorsContent, SponsorsContent = SeedCatalog.DefaultSponsorsContent,
ShowactsUrl = "https://vtuber-star-awards.de/showacts", 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), RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.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), NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
ClipSubmissionsEnabled = false, ClipSubmissionsEnabled = false,
ClipReviewEnabled = true, ClipReviewEnabled = true,
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
ShowactApplicationsEnabled = false, ShowactApplicationsEnabled = false,
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
SessionIdleTimeoutHours = 3,
SponsorsVisible = true, SponsorsVisible = true,
SocialLinksJson = JsonSerializer.Serialize(new[] SocialLinksJson = JsonSerializer.Serialize(new[]
{ {
@@ -152,7 +160,7 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
}); });
modelBuilder.Entity<Category>().HasData( 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 = 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 = 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 }, 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(); .ToArrayAsync();
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db); var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
await EnsureSeedReviewNominationsAsync(db, season, categories, candidates);
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id)) if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
{ {
@@ -23,15 +24,15 @@ public static partial class SeedDataBootstrapper
new ClipSubmission new ClipSubmission
{ {
SeasonId = season.Id, SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"), CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"), CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"),
SubmittedByTwitchId = "local_user_3", SubmittedByTwitchId = "local_user_3",
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment", ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
Title = "Starlight Debut Moment", Title = "Starlight Debut Moment",
Creator = "Hoshimi Miyu", Creator = "Hoshimi Miyu",
Platform = "Twitch", Platform = "Twitch",
Status = "approved", Status = "approved",
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.", ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin", ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1", CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero), CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
@@ -40,15 +41,15 @@ public static partial class SeedDataBootstrapper
new ClipSubmission new ClipSubmission
{ {
SeasonId = season.Id, SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"), CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"), CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"),
SubmittedByTwitchId = "local_user_4", SubmittedByTwitchId = "local_user_4",
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype", ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
Title = "Finale-Hype mit Chat-Chaos", Title = "Finale-Hype mit Chat-Chaos",
Creator = "Kurainu", Creator = "Kurainu",
Platform = "Twitch", Platform = "Twitch",
Status = "approved", Status = "approved",
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.", ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin", ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1", CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero), CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
@@ -57,8 +58,8 @@ public static partial class SeedDataBootstrapper
new ClipSubmission new ClipSubmission
{ {
SeasonId = season.Id, SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "best-gaming"), CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"), CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"),
SubmittedByTwitchId = "local_user", SubmittedByTwitchId = "local_user",
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment", ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
Title = "Epischer Clutch im Finale", Title = "Epischer Clutch im Finale",
@@ -71,15 +72,15 @@ public static partial class SeedDataBootstrapper
new ClipSubmission new ClipSubmission
{ {
SeasonId = season.Id, SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "gesang-musik"), CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"), CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"),
SubmittedByTwitchId = "local_user_2", SubmittedByTwitchId = "local_user_2",
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment", ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
Title = "Live-Cover mit Gänsehaut", Title = "Live-Cover mit Gänsehaut",
Creator = "Melo Diva", Creator = "Melo Diva",
Platform = "YouTube", Platform = "YouTube",
Status = "approved", Status = "approved",
ReviewNote = "Geprüfter Clip fuer Review-Workflow.", ReviewNote = "Geprüfter Clip für Review-Workflow.",
ReviewedByTwitchId = "jayuhime_admin", ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1", CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero), CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
@@ -114,7 +115,7 @@ public static partial class SeedDataBootstrapper
EntityType = "database", EntityType = "database",
EntityId = season.Year.ToString(), EntityId = season.Year.ToString(),
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.", 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", CreatedFromIp = "seed",
UserAgent = "seed-bootstrap", UserAgent = "seed-bootstrap",
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero), CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
@@ -279,5 +280,275 @@ public static partial class SeedDataBootstrapper
.Where(char.IsLetterOrDigit) .Where(char.IsLetterOrDigit)
.ToArray()); .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); 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); 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)) if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
{ {
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults); settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
@@ -83,7 +111,12 @@ public static partial class SeedDataBootstrapper
if (string.IsNullOrWhiteSpace(settings.ShowactsContent)) 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) => private static bool HasValidWorkflowRules(string? json) =>
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length; 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) private static bool HasValidNominationLinkBlacklist(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
+3
View File
@@ -7,9 +7,12 @@ public sealed class Candidate
public Season Season { get; set; } = null!; public Season Season { get; set; } = null!;
public int CategoryId { get; set; } public int CategoryId { get; set; }
public Category Category { get; set; } = null!; 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 DisplayName { get; set; } = string.Empty;
public string ChannelSlug { get; set; } = string.Empty; public string ChannelSlug { get; set; } = string.Empty;
public string Platform { get; set; } = "Twitch"; public string Platform { get; set; } = "Twitch";
public int NominationTally { get; set; }
public string AcceptanceStatus { get; set; } = "open"; public string AcceptanceStatus { get; set; } = "open";
public string? AcceptanceNote { get; set; } public string? AcceptanceNote { get; set; }
public string? ClipCompilationUrl { 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 string Description { get; set; } = string.Empty;
public int SortOrder { get; set; } public int SortOrder { get; set; }
public int MaxNomineesPerUser { get; set; } public int MaxNomineesPerUser { get; set; }
public int? ViewerRangeMin { get; set; }
public int? ViewerRangeMax { get; set; }
public ICollection<Candidate> Candidates { 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 Id { get; set; }
public int SeasonId { get; set; } public int SeasonId { get; set; }
public Season Season { get; set; } = null!; public Season Season { get; set; } = null!;
public int CategoryId { get; set; } public int? CategoryId { get; set; }
public Category Category { get; set; } = null!; public Category? Category { get; set; }
public string CategoryGroupName { get; set; } = string.Empty;
public string SubmittedByTwitchId { get; set; } = string.Empty; public string SubmittedByTwitchId { get; set; } = string.Empty;
public int? CandidateId { get; set; } public int? CandidateId { get; set; }
public Candidate? Candidate { 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? CandidateText { get; set; }
public string? StreamUrl { 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 Status { get; set; } = "pending";
public string? ReviewNote { get; set; } public string? ReviewNote { get; set; }
public string? ReviewedByTwitchId { 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 ReviewEndsAt { get; set; }
public DateOnly ShowDate { get; set; } public DateOnly ShowDate { get; set; }
public TimeOnly ShowStartsAt { get; set; } = new(20, 0); 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<Category> Categories { get; set; } = [];
public ICollection<AwardResult> Results { 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 Description { get; set; } = string.Empty;
public string TechnicalNotes { get; set; } = string.Empty; public string TechnicalNotes { get; set; } = string.Empty;
public string ReferenceUrl { get; set; } = string.Empty; public string ReferenceUrl { get; set; } = string.Empty;
public string FieldResponsesJson { get; set; } = "{}";
public string Status { get; set; } = "pending"; public string Status { get; set; } = "pending";
public string? ReviewNote { get; set; } public string? ReviewNote { get; set; }
public string? ReviewedByTwitchId { 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 HostDisplayName { get; set; } = string.Empty;
public string HostTagline { get; set; } = string.Empty; public string HostTagline { get; set; } = string.Empty;
public string NewsletterUrl { 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 PrivacyEmail { get; set; } = string.Empty;
public string PrivacyPolicyContent { get; set; } = string.Empty; public string PrivacyPolicyContent { get; set; } = string.Empty;
public string? PrivacyPolicyUpdatedBy { get; set; } public string? PrivacyPolicyUpdatedBy { get; set; }
@@ -22,13 +24,19 @@ public sealed class SiteSettings
public string FaqJson { get; set; } = "[]"; public string FaqJson { get; set; } = "[]";
public string RiskRulesJson { get; set; } = "[]"; public string RiskRulesJson { get; set; } = "[]";
public string WorkflowRulesJson { 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 string NominationLinkBlacklistJson { get; set; } = "[]";
public bool ClipSubmissionsEnabled { get; set; } public bool ClipSubmissionsEnabled { get; set; }
public bool ClipReviewEnabled { get; set; } = true; public bool ClipReviewEnabled { get; set; } = true;
public bool ClipAdminMenuVisible { get; set; } = true; public bool ClipAdminMenuVisible { get; set; } = true;
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen."; public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
public bool ShowactApplicationsEnabled { get; set; } 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 ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
public string ShowactFormSchemaJson { get; set; } = "[]";
public bool SponsorsVisible { get; set; } = true; public bool SponsorsVisible { get; set; } = true;
public bool DemoLoginManagedByDatabase { get; set; } public bool DemoLoginManagedByDatabase { get; set; }
public bool DemoLoginEnabled { get; set; } public bool DemoLoginEnabled { get; set; }
@@ -42,6 +50,7 @@ public sealed class SiteSettings
public string TwitchClientSecret { get; set; } = string.Empty; public string TwitchClientSecret { get; set; } = string.Empty;
public string TwitchRedirectUri { get; set; } = string.Empty; public string TwitchRedirectUri { get; set; } = string.Empty;
public string TwitchScope { get; set; } = string.Empty; public string TwitchScope { get; set; } = string.Empty;
public int SessionIdleTimeoutHours { get; set; } = 3;
public bool MaintenanceModeEnabled { get; set; } public bool MaintenanceModeEnabled { get; set; }
public string MaintenanceTitle { get; set; } = "Sternenpause"; public string MaintenanceTitle { get; set; } = "Sternenpause";
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."; 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.Status,
application.ReviewNote, application.ReviewNote,
application.CreatedAt, application.CreatedAt,
application.ReviewedAt); application.ReviewedAt,
application.FieldResponsesJson ?? "{}");
private static string NormalizeStatus(string? value) => private static string NormalizeStatus(string? value) =>
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant(); 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)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("RejectAdminNomination") .WithName("RejectAdminNomination")
.WithOpenApi(); .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) group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("GetAdminNominationLinkBlacklist") .WithName("GetAdminNominationLinkBlacklist")
@@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints
var session = AdminEndpointConventions.CurrentSession(context); var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations var nomination = await db.Nominations
.Include(item => item.Category) .Include(item => item.Category)
.Include(item => item.SuggestedCategory)
.Include(item => item.StreamerIdentity)
.FirstOrDefaultAsync(item => item.Id == nominationId); .FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null) if (nomination is null)
@@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints
return Results.NotFound(); 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)) if (string.IsNullOrWhiteSpace(rawDisplayName))
{ {
return Results.BadRequest(new { message = "A display name is required to approve the nomination." }); return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
} }
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty; var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim(); 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 normalizedDisplayName = rawDisplayName.ToLower();
var normalizedChannelSlug = channelSlug.ToLower(); var normalizedChannelSlug = channelSlug.ToLower();
var normalizedPlatform = platform.ToLower(); var normalizedPlatform = platform.ToLower();
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item => var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
item.SeasonId == nomination.SeasonId item.SeasonId == nomination.SeasonId
&& item.CategoryId == nomination.CategoryId && item.CategoryId == targetCategory.Id
&& ( && (
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
||
item.DisplayName.ToLower() == normalizedDisplayName item.DisplayName.ToLower() == normalizedDisplayName
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug) || (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
&& item.ChannelSlug.ToLower() == normalizedChannelSlug && item.ChannelSlug.ToLower() == normalizedChannelSlug
&& item.Platform.ToLower() == normalizedPlatform) && 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; var candidate = existingCandidate;
if (candidate is null) if (candidate is null)
{ {
candidate = new Candidate candidate = new Candidate
{ {
SeasonId = nomination.SeasonId, SeasonId = nomination.SeasonId,
CategoryId = nomination.CategoryId, CategoryId = targetCategory.Id,
StreamerIdentityId = nomination.StreamerIdentityId,
DisplayName = rawDisplayName, DisplayName = rawDisplayName,
ChannelSlug = channelSlug, ChannelSlug = channelSlug,
Platform = platform, Platform = platform,
@@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints
} }
else else
{ {
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
candidate.DisplayName = rawDisplayName; candidate.DisplayName = rawDisplayName;
if (!string.IsNullOrWhiteSpace(channelSlug)) if (!string.IsNullOrWhiteSpace(channelSlug))
@@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints
} }
} }
nomination.CandidateId = candidate.Id; var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
nomination.Status = "approved"; var uniqueViewerCount = relatedNominations
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); .Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
nomination.ReviewedAt = DateTimeOffset.UtcNow; .Where(item => !string.IsNullOrWhiteSpace(item))
nomination.ReviewedByTwitchId = session.TwitchUserId; .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( adminAuditService.AddEntry(
session.TwitchUserId, session.TwitchUserId,
"nomination.approve", "nomination.approve",
"nomination", "nomination",
nomination.Id.ToString(), nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.", $"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote }, 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)); RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted); 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( private static async Task<IResult> RejectNomination(
@@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints
return Results.NotFound(); return Results.NotFound();
} }
nomination.CandidateId = null; var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
nomination.Status = "rejected"; foreach (var relatedNomination in relatedNominations)
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); {
nomination.ReviewedAt = DateTimeOffset.UtcNow; relatedNomination.CandidateId = null;
nomination.ReviewedByTwitchId = session.TwitchUserId; 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( adminAuditService.AddEntry(
session.TwitchUserId, session.TwitchUserId,
@@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints
"nomination", "nomination",
nomination.Id.ToString(), nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde verworfen.", $"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)); RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted); 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, seasonId,
request.CategoryId, request.CategoryId,
null, null,
null,
normalizedDisplayName, normalizedDisplayName,
normalizedChannelSlug, normalizedChannelSlug,
normalizedAcceptanceStatus, normalizedAcceptanceStatus,
@@ -158,6 +159,7 @@ public static partial class AdminSeasonManagementEndpoints
candidate.SeasonId, candidate.SeasonId,
request.CategoryId, request.CategoryId,
candidateId, candidateId,
candidate.StreamerIdentityId,
normalizedDisplayName, normalizedDisplayName,
normalizedChannelSlug, normalizedChannelSlug,
normalizedAcceptanceStatus, normalizedAcceptanceStatus,
@@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints
Description = request.Description.Trim(), Description = request.Description.Trim(),
SortOrder = request.SortOrder, SortOrder = request.SortOrder,
MaxNomineesPerUser = request.MaxNomineesPerUser, MaxNomineesPerUser = request.MaxNomineesPerUser,
ViewerRangeMin = request.ViewerRangeMin,
ViewerRangeMax = request.ViewerRangeMax,
}; };
db.Categories.Add(category); db.Categories.Add(category);
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
category.Description = request.Description.Trim(); category.Description = request.Description.Trim();
category.SortOrder = request.SortOrder; category.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser; category.MaxNomineesPerUser = request.MaxNomineesPerUser;
category.ViewerRangeMin = request.ViewerRangeMin;
category.ViewerRangeMax = request.ViewerRangeMax;
adminAuditService.AddEntry( adminAuditService.AddEntry(
session.TwitchUserId, 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 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 var season = new Season
{ {
@@ -45,6 +49,8 @@ public static partial class AdminSeasonManagementEndpoints
ReviewEndsAt = request.ReviewEndsAt, ReviewEndsAt = request.ReviewEndsAt,
ShowDate = request.ShowDate, ShowDate = request.ShowDate,
ShowStartsAt = request.ShowStartsAt, ShowStartsAt = request.ShowStartsAt,
SubcategoryTemplatesJson = "[]",
WorkflowRulesJson = initialWorkflowRulesJson,
}; };
db.Seasons.Add(season); db.Seasons.Add(season);
@@ -72,6 +78,13 @@ public static partial class AdminSeasonManagementEndpoints
.ToArrayAsync(context.RequestAborted); .ToArrayAsync(context.RequestAborted);
copiedCategoryCount = sourceCategories.Length; 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) foreach (var category in sourceCategories)
{ {
db.Categories.Add(new Category db.Categories.Add(new Category
@@ -83,6 +96,8 @@ public static partial class AdminSeasonManagementEndpoints
Description = category.Description, Description = category.Description,
SortOrder = category.SortOrder, SortOrder = category.SortOrder,
MaxNomineesPerUser = category.MaxNomineesPerUser, MaxNomineesPerUser = category.MaxNomineesPerUser,
ViewerRangeMin = category.ViewerRangeMin,
ViewerRangeMax = category.ViewerRangeMax,
}); });
} }
} }
+380 -7
View File
@@ -1,5 +1,6 @@
using Backend.Contracts; using Backend.Contracts;
using Backend.Data; using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints; namespace Backend.Endpoints;
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
var trackingRules = TrackingRulesSettings.Read(settings);
var candidates = await db.Candidates var candidates = await db.Candidates
.AsNoTracking() .AsNoTracking()
.Where(item => item.SeasonId == seasonId) .Where(item => item.SeasonId == seasonId)
@@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints
.Select(item => new AdminCandidateItemDto( .Select(item => new AdminCandidateItemDto(
item.Id, item.Id,
item.CategoryId, item.CategoryId,
item.StreamerIdentityId,
item.DisplayName, item.DisplayName,
item.ChannelSlug, item.ChannelSlug,
item.Platform, item.Platform,
item.NominationTally,
item.AcceptanceStatus, item.AcceptanceStatus,
item.AcceptanceNote, item.AcceptanceNote,
item.ClipCompilationUrl, item.ClipCompilationUrl,
@@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
category.Description, category.Description,
category.SortOrder, category.SortOrder,
category.MaxNomineesPerUser, category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
}) })
.ToArrayAsync(); .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( .Select(category => new AdminCategoryItemDto(
category.Id, category.Id,
category.GroupName, category.GroupName,
@@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
category.Description, category.Description,
category.SortOrder, category.SortOrder,
category.MaxNomineesPerUser, category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0)) candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
.ToArray(); .ToArray();
var pendingNominations = await db.Nominations var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
var pendingNominationRows = await db.Nominations
.AsNoTracking() .AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status == "pending") .Where(item => item.SeasonId == seasonId && item.Status == "pending")
.OrderByDescending(item => item.CreatedAt) .OrderByDescending(item => item.CreatedAt)
.Select(item => new AdminNominationReviewItemDto( .Select(item => new AdminNominationRow(
item.Id, item.Id,
item.CategoryId, item.CategoryId,
item.Category.Name, item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
item.CategoryId != null ? item.Category!.Name : null,
item.SubmittedByTwitchId, item.SubmittedByTwitchId,
item.CandidateText ?? string.Empty, item.CandidateText ?? string.Empty,
item.StreamUrl, 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.Status,
item.CreatedAt, item.CreatedAt,
item.CandidateId, item.CandidateId,
@@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
item.ReviewedAt)) item.ReviewedAt))
.ToArrayAsync(); .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() .AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status != "pending") .Where(item => item.SeasonId == seasonId && item.Status != "pending")
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt) .OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
.Select(item => new AdminNominationReviewItemDto( .Select(item => new AdminNominationRow(
item.Id, item.Id,
item.CategoryId, item.CategoryId,
item.Category.Name, item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
item.CategoryId != null ? item.Category!.Name : null,
item.SubmittedByTwitchId, item.SubmittedByTwitchId,
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty), item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
item.StreamUrl, 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.Status,
item.CreatedAt, item.CreatedAt,
item.CandidateId, item.CandidateId,
@@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
item.ReviewedAt)) item.ReviewedAt))
.ToArrayAsync(); .ToArrayAsync();
var reviewedNominations = reviewedNominationRows
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
.ToArray();
var resultItems = await db.Results var resultItems = await db.Results
.AsNoTracking() .AsNoTracking()
.Where(item => item.SeasonId == seasonId) .Where(item => item.SeasonId == seasonId)
@@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints
item.CategoryId, item.CategoryId,
item.Category.Name, item.Category.Name,
item.CandidateId, item.CandidateId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName, item.Candidate.DisplayName,
item.Candidate.ChannelSlug, item.Candidate.ChannelSlug,
item.Candidate.Platform)) item.Candidate.Platform))
@@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints
season.ReviewEndsAt, season.ReviewEndsAt,
season.ShowDate, season.ShowDate,
season.ShowStartsAt, season.ShowStartsAt,
subcategoryTemplates,
categories, categories,
candidates, candidates,
pendingNominations, pendingNominations,
pendingNominationGroups,
reviewedNominations, reviewedNominations,
settings?.TrackingReviewNotes ?? string.Empty,
trackingRules.Source.ShowManualReviewNotesInReview,
resultItems, resultItems,
clipSubmissions)); 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)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("DeleteAdminCategory") .WithName("DeleteAdminCategory")
.WithOpenApi(); .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) group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("CreateAdminCandidate") .WithName("CreateAdminCandidate")
@@ -56,11 +72,11 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("DeleteAdminResult") .WithName("DeleteAdminResult")
.WithOpenApi(); .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)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("GetAdminWorkflowRules") .WithName("GetAdminWorkflowRules")
.WithOpenApi(); .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)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("UpdateAdminWorkflowRules") .WithName("UpdateAdminWorkflowRules")
.WithOpenApi(); .WithOpenApi();
@@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints
private sealed record CandidateRuleSnapshot( private sealed record CandidateRuleSnapshot(
int Id, int Id,
int CategoryId, int CategoryId,
int? StreamerIdentityId,
string DisplayName, string DisplayName,
string ChannelSlug, string ChannelSlug,
string AcceptanceStatus); 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) private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
{ {
if (request.Year < 2020 || request.Year > 2100) 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 var settings = await db.SiteSettings
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); .FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
return WorkflowRuleSettings.Read(settings); return WorkflowRuleSettings.Read(season, settings);
} }
private static IResult CreateWorkflowRuleError(string message) => private static IResult CreateWorkflowRuleError(string message) =>
@@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints
int seasonId, int seasonId,
int categoryId, int categoryId,
int? existingCandidateId, int? existingCandidateId,
int? streamerIdentityId,
string displayName, string displayName,
string channelSlug, string channelSlug,
string acceptanceStatus, string acceptanceStatus,
@@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints
return null; return null;
} }
var rules = await LoadWorkflowRulesAsync(db, cancellationToken); var rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory); var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances); var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule)) if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
@@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints
.Select(item => new CandidateRuleSnapshot( .Select(item => new CandidateRuleSnapshot(
item.Id, item.Id,
item.CategoryId, item.CategoryId,
item.StreamerIdentityId,
item.DisplayName, item.DisplayName,
item.ChannelSlug, item.ChannelSlug,
item.AcceptanceStatus)) item.AcceptanceStatus))
@@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints
{ {
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug); var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
var appearanceCount = existingCandidates.Count(item => 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) if (appearanceCount >= appearancesRule.Limit)
{ {
return CreateWorkflowRuleError( return CreateWorkflowRuleError(
@@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints
bool isCurrent, bool isCurrent,
CancellationToken cancellationToken) 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 phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent); var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey); var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
@@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints
return []; 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 var categoryIds = await db.Categories
.AsNoTracking() .AsNoTracking()
.Where(item => item.SeasonId == seasonId) .Where(item => item.SeasonId == seasonId)
@@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints
if (needsCandidateReadiness && categoryIds.Length > 0) if (needsCandidateReadiness && categoryIds.Length > 0)
{ {
var categoriesWithCandidates = await db.Candidates var candidateSnapshots = await db.Candidates
.AsNoTracking() .AsNoTracking()
.Where(item => item.SeasonId == seasonId) .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) .Select(item => item.CategoryId)
.Distinct() .Distinct()
.CountAsync(cancellationToken); .Count();
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates); var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
if (emptyCategories > 0) 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 (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 var categoriesWithResults = await db.Results
.AsNoTracking() .AsNoTracking()
.Where(item => item.SeasonId == seasonId) .Where(item => item.SeasonId == seasonId)
@@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints
{ {
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner."); 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(); 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) private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
{ {
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal)) 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." }); 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; 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." }); 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); var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule) if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)) && string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
@@ -56,11 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
.Select(item => new .Select(item => new
{ {
item.CategoryId, item.CategoryId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName, item.Candidate.DisplayName,
item.Candidate.ChannelSlug, item.Candidate.ChannelSlug,
}) })
.ToArrayAsync(context.RequestAborted); .ToArrayAsync(context.RequestAborted);
var existingWinnerCount = existingWinnerIdentities.Count(item => var existingWinnerCount = existingWinnerIdentities.Count(item =>
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal)); string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
if (existingWinnerCount >= winnerPlacementsRule.Limit) 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)) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminOptionalFeatureSettings") .WithName("UpdateAdminOptionalFeatureSettings")
.WithOpenApi(); .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; return group;
} }
@@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName, settings.HostDisplayName,
settings.HostTagline, settings.HostTagline,
settings.NewsletterUrl, settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail, settings.PrivacyEmail,
settings.PrivacyPolicyContent, settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy, settings.PrivacyPolicyUpdatedBy,
@@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints
settings.ShowactsUrl, settings.ShowactsUrl,
settings.ShowactsContent, settings.ShowactsContent,
SeasonMappings.ReadSocialLinks(settings), SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings))); SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]"));
} }
private static async Task<IResult> UpdateSiteSettings( private static async Task<IResult> UpdateSiteSettings(
@@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName = request.HostDisplayName.Trim(); settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim(); settings.HostTagline = request.HostTagline.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl; settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim(); settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim(); var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal); var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
@@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints
settings.ShowactsContent = request.ShowactsContent.Trim(); settings.ShowactsContent = request.ShowactsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks); settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []); settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
adminAuditService.AddEntry( adminAuditService.AddEntry(
session.TwitchUserId, session.TwitchUserId,
@@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints
socialLinks = []; socialLinks = [];
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage) 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.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage) || !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage) || !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
@@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints
normalizedUrls = new PublicSiteUrlSettings normalizedUrls = new PublicSiteUrlSettings
{ {
NewsletterUrl = newsletterUrl, NewsletterUrl = newsletterUrl,
ShareXUrl = shareXUrl,
ShareDiscordUrl = shareDiscordUrl,
ImprintUrl = imprintUrl, ImprintUrl = imprintUrl,
ContactUrl = contactUrl, ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl, SponsorsUrl = sponsorsUrl,
@@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints
private sealed class PublicSiteUrlSettings private sealed class PublicSiteUrlSettings
{ {
public string NewsletterUrl { 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 ImprintUrl { get; set; } = string.Empty; public string ImprintUrl { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty; public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty; public string SponsorsUrl { get; set; } = string.Empty;
@@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(ToOptionalFeatureSettingsResponse(settings)); 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( private static async Task<IResult> UpdateOptionalFeatureSettings(
HttpContext context, HttpContext context,
UpdateOptionalFeatureSettingsRequest request, UpdateOptionalFeatureSettingsRequest request,
@@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints
} }
var before = ToOptionalFeatureSettingsResponse(settings); 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( var disabledMessage = NormalizeOptionalFeatureText(
request.ClipSubmissionDisabledMessage, request.ClipSubmissionDisabledMessage,
FallbackClipSubmissionDisabledMessage, FallbackClipSubmissionDisabledMessage,
@@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible; settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
settings.ClipSubmissionDisabledMessage = disabledMessage; settings.ClipSubmissionDisabledMessage = disabledMessage;
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled; settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt;
settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt;
settings.ShowactApplicationDisabledMessage = showactDisabledMessage; settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
settings.SponsorsVisible = request.SponsorsVisible; settings.SponsorsVisible = request.SponsorsVisible;
@@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints
? FallbackClipSubmissionDisabledMessage ? FallbackClipSubmissionDisabledMessage
: settings.ClipSubmissionDisabledMessage, : settings.ClipSubmissionDisabledMessage,
settings.ShowactApplicationsEnabled, settings.ShowactApplicationsEnabled,
settings.ShowactApplicationStartsAt,
settings.ShowactApplicationEndsAt,
ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)),
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage) string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen." ? "Showact-Bewerbungen sind aktuell geschlossen."
: settings.ShowactApplicationDisabledMessage, : settings.ShowactApplicationDisabledMessage,
@@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible); AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage); AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled); 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, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible); AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
return changes.ToArray(); return changes.ToArray();
@@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings); var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration); var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
return Results.Ok(new AdminOperationalSettingsResponse( return Results.Ok(new AdminOperationalSettingsResponse(
usesDatabaseDemo, usesDatabaseDemo,
@@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints
twitchSettings.ClientSecretSet, twitchSettings.ClientSecretSet,
twitchSettings.RedirectUri, twitchSettings.RedirectUri,
twitchSettings.Scope, twitchSettings.Scope,
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
settings.MaintenanceModeEnabled, settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
@@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty; var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
var twitchRedirectUri = request.TwitchRedirectUri.Trim(); var twitchRedirectUri = request.TwitchRedirectUri.Trim();
var twitchScope = request.TwitchScope.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) var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET")); || !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
@@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints
settings.TwitchClientSecret, settings.TwitchClientSecret,
settings.TwitchRedirectUri, settings.TwitchRedirectUri,
settings.TwitchScope); settings.TwitchScope);
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword) var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
? newPassword ? newPassword
@@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints
"operational-settings.update", "operational-settings.update",
"site-settings", "site-settings",
settings.Id.ToString(), settings.Id.ToString(),
"Demo-Zugang und Wartungsmodus wurden aktualisiert.", "Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
new new
{ {
settings.DemoLoginEnabled, settings.DemoLoginEnabled,
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist), passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
settings.SessionIdleTimeoutHours,
settings.MaintenanceModeEnabled, settings.MaintenanceModeEnabled,
changes, changes,
}, },
@@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints
HasEffectiveTwitchClientSecret(settings, configuration), HasEffectiveTwitchClientSecret(settings, configuration),
settings.TwitchRedirectUri, settings.TwitchRedirectUri,
settings.TwitchScope, settings.TwitchScope,
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
settings.MaintenanceModeEnabled, settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage); 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, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri); AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope); AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged) if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
{ {
@@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints
bool TwitchClientSecretSet, bool TwitchClientSecretSet,
string TwitchRedirectUri, string TwitchRedirectUri,
string TwitchScope, string TwitchScope,
int SessionIdleTimeoutHours,
bool MaintenanceModeEnabled, bool MaintenanceModeEnabled,
string MaintenanceTitle, string MaintenanceTitle,
string MaintenanceMessage); string MaintenanceMessage);
@@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints
string RedirectUri, string RedirectUri,
string Scope, string Scope,
bool Configured); 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 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() .AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1); .FirstOrDefaultAsync(item => item.Id == seasonId);
if (settings is null) if (season is null)
{ {
return Results.NotFound(); 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( private static async Task<IResult> UpdateWorkflowRules(
HttpContext context, HttpContext context,
int seasonId,
UpdateWorkflowRulesRequest request, UpdateWorkflowRulesRequest request,
AwardsDbContext db, AwardsDbContext db,
IAdminAuditService adminAuditService) IAdminAuditService adminAuditService)
{ {
var session = AdminEndpointConventions.CurrentSession(context); var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
if (settings is null) if (season is null)
{ {
return Results.NotFound(); 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 var mergedRules = WorkflowRuleSettings.Defaults
.Select(defaultRule => .Select(defaultRule =>
{ {
@@ -51,8 +59,8 @@ public static partial class AdminSeasonManagementEndpoints
}) })
.ToArray(); .ToArray();
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules); season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
var after = WorkflowRuleSettings.Read(settings); var after = WorkflowRuleSettings.Read(season, settings);
var changes = after var changes = after
.Select(rule => .Select(rule =>
{ {
@@ -72,10 +80,10 @@ public static partial class AdminSeasonManagementEndpoints
adminAuditService.AddEntry( adminAuditService.AddEntry(
session.TwitchUserId, session.TwitchUserId,
"workflow-rules.update", "workflow-rules.update",
"site-settings", "season",
settings.Id.ToString(), season.Id.ToString(),
"Workflow-Regeln wurden aktualisiert.", $"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.",
new { changes }, new { seasonId = season.Id, season.Year, changes },
RequestMetadataReader.Read(context)); RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted); await db.SaveChangesAsync(context.RequestAborted);
+38 -19
View File
@@ -12,6 +12,7 @@ public static partial class AuthEndpoints
{ {
private static async Task<IResult> DemoLogin( private static async Task<IResult> DemoLogin(
HttpContext context, HttpContext context,
IHostEnvironment environment,
AwardsDbContext db, AwardsDbContext db,
IConfiguration configuration, IConfiguration configuration,
DemoLoginRequest request, DemoLoginRequest request,
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
var password = request.Password ?? string.Empty; var password = request.Password ?? string.Empty;
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
var databaseDemoConfigured = settings is not null var databaseDemoConfigured = settings is not null
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings)); && settings.DemoLoginManagedByDatabase;
string twitchUserId; string twitchUserId;
string displayName; string displayName;
bool credentialsMatch; 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) if (databaseDemoConfigured && settings is not null)
{ {
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt); && DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
twitchUserId = settings.DemoLoginTwitchUserId.Trim(); twitchUserId = settings.DemoLoginTwitchUserId.Trim();
displayName = settings.DemoLoginDisplayName.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 else
{ {
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var configuredLogin = ReadDemoLoginIdentifier(configuration); if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL"); || string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD"); || string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"); || string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
if (string.IsNullOrWhiteSpace(configuredLogin)
|| string.IsNullOrWhiteSpace(configuredPassword)
|| string.IsNullOrWhiteSpace(twitchUserId)
|| string.IsNullOrWhiteSpace(displayName))
{ {
return Results.Json( return Results.Json(
new { message = "Demo login is not fully configured." }, new { message = "Demo login is not fully configured." },
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
credentialsMatch = LoginMatchesIdentifier( credentialsMatch = LoginMatchesIdentifier(
login, login,
configuredLogin, fallbackConfiguredLogin,
configuredEmail, fallbackConfiguredEmail,
twitchUserId, fallbackConfiguredTwitchUserId,
displayName) fallbackConfiguredDisplayName)
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword); && DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
twitchUserId = twitchUserId.Trim(); twitchUserId = fallbackConfiguredTwitchUserId.Trim();
displayName = displayName.Trim(); displayName = fallbackConfiguredDisplayName.Trim();
} }
if (!credentialsMatch) if (!credentialsMatch)
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
await db.SaveChangesAsync(context.RequestAborted); 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) private static bool IsDemoLoginEnabled(IConfiguration configuration)
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
await db.SaveChangesAsync(context.RequestAborted); 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.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) 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( private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
AwardsDbContext db, AwardsDbContext db,
IUserSessionService userSessionService,
UserSession session, UserSession session,
bool mustChangePassword = false, bool mustChangePassword = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken); var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
var sessionRole = teamMember?.Role ?? session.Role; var sessionRole = teamMember?.Role ?? session.Role;
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken); var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
return new( return new(
session.SessionToken, session.SessionToken,
session.TwitchUserId, session.TwitchUserId,
teamMember?.DisplayName ?? session.DisplayName, teamMember?.DisplayName ?? session.DisplayName,
AdminRoles.Normalize(sessionRole), AdminRoles.Normalize(sessionRole),
permissionKeys, permissionKeys,
sessionIdleTimeoutHours,
teamMember?.MustChangePassword ?? mustChangePassword, teamMember?.MustChangePassword ?? mustChangePassword,
teamMember?.Login, teamMember?.Login,
teamMember?.BoundTwitchUserId, teamMember?.BoundTwitchUserId,
+2 -2
View File
@@ -44,7 +44,7 @@ public static partial class AuthEndpoints
context.RequestAborted); context.RequestAborted);
await db.SaveChangesAsync(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( private static async Task<IResult> ChangePassword(
@@ -92,7 +92,7 @@ public static partial class AuthEndpoints
session.Role = AdminRoles.Normalize(member.Role); session.Role = AdminRoles.Normalize(member.Role);
await db.SaveChangesAsync(context.RequestAborted); 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) => private static string BuildTeamSessionId(string login) =>
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
return Results.Ok(new TwitchBindingDisconnectResponse( return Results.Ok(new TwitchBindingDisconnectResponse(
false, false,
false, false,
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
} }
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId); var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
currentSessionUsesBoundTwitch, currentSessionUsesBoundTwitch,
currentSessionUsesBoundTwitch currentSessionUsesBoundTwitch
? null ? null
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); : await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
} }
private static async Task<IResult> CompleteTwitchTeamLoginAsync( 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.Common;
using Backend.Contracts; using Backend.Contracts;
using Backend.Data; using Backend.Data;
@@ -8,6 +11,8 @@ namespace Backend.Endpoints;
public static partial class PublicEndpoints 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) private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
{ {
var season = await db.Seasons var season = await db.Seasons
@@ -45,13 +50,19 @@ public static partial class PublicEndpoints
return Results.Ok(new PublicSponsorsResponse(year, sponsors)); return Results.Ok(new PublicSponsorsResponse(year, sponsors));
} }
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private static async Task<IResult> CreateShowactApplication( private static async Task<IResult> CreateShowactApplication(
HttpContext context, HttpContext context,
CreateShowactApplicationRequest request, CreateShowactApplicationRequest request,
AwardsDbContext db) AwardsDbContext db)
{ {
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); 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 return Results.BadRequest(new
{ {
@@ -67,55 +78,137 @@ public static partial class PublicEndpoints
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." }); return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
} }
var artistName = NormalizePublicText(request.ArtistName, 120); if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
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." }); 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 });
} }
else
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
{ {
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)) private sealed class ShowactFieldDefinition
{ {
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." }); [JsonPropertyName("id")] public string Id { get; set; } = "";
} [JsonPropertyName("type")] public string Type { get; set; } = "";
[JsonPropertyName("label")] public string Label { get; set; } = "";
var metadata = RequestMetadataReader.Read(context); [JsonPropertyName("required")] public bool Required { get; set; }
var application = new ShowactApplication [JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
{ [JsonPropertyName("maxLength")] public int MaxLength { get; set; }
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 static string NormalizePublicText(string? value, int maxLength) private static string NormalizePublicText(string? value, int maxLength)
@@ -128,4 +221,167 @@ public static partial class PublicEndpoints
string.IsNullOrWhiteSpace(value) string.IsNullOrWhiteSpace(value)
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri) || (Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)); && (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, AwardsDbContext db,
IUserSessionService userSessionService, IUserSessionService userSessionService,
IRiskFlagService riskFlagService, IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService) IRiskRuleService riskRuleService,
NominationEnrichmentService nominationEnrichmentService)
{ {
var submittedNominations = NormalizeSubmittedNominations(request); 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 })) if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
@@ -35,7 +36,7 @@ public static partial class PublicEndpoints
} }
var distinctStreamUrls = submittedNominations var distinctStreamUrls = submittedNominations
.Select(item => item.StreamUrl) .Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .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." }); 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 var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.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) if (nominationSeasonResolution.Result is not null)
{ {
return nominationSeasonResolution.Result; return nominationSeasonResolution.Result;
@@ -89,15 +111,16 @@ public static partial class PublicEndpoints
var submitterId = submitterIdResult.SubmitterId!; var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context); var requestMetadata = RequestMetadataReader.Read(context);
var existingNominationCount = await db.Nominations.CountAsync(item => var existingNominationCount = await db.Nominations.CountAsync(item =>
item.SeasonId == category.SeasonId item.SeasonId == season.Id
&& item.CategoryId == category.Id && item.CategoryGroupName == categoryGroupName
&& item.SubmittedByTwitchId == submitterId && item.SubmittedByTwitchId == submitterId
&& item.Status == "pending"); && item.Status == "pending");
var records = submittedNominations.Select(nomination => new Nomination var records = submittedNominations.Select(nomination => new Nomination
{ {
SeasonId = category.SeasonId, SeasonId = season.Id,
CategoryId = category.Id, CategoryId = null,
CategoryGroupName = categoryGroupName,
SubmittedByTwitchId = submitterId, SubmittedByTwitchId = submitterId,
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name, CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl, StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
@@ -106,6 +129,11 @@ public static partial class PublicEndpoints
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}).ToArray(); }).ToArray();
foreach (var record in records)
{
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
}
await db.Nominations.AddRangeAsync(records); await db.Nominations.AddRangeAsync(records);
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted); var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
@@ -126,21 +154,21 @@ public static partial class PublicEndpoints
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled) if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
{ {
await riskFlagService.AddIfMissingAsync( await riskFlagService.AddIfMissingAsync(
category.SeasonId, season.Id,
submitterId, submitterId,
"nomination", "nomination",
"resubmitted_nomination", "resubmitted_nomination",
resubmittedNominationRule.Severity, resubmittedNominationRule.Severity,
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.", "Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
requestMetadata, 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); context.RequestAborted);
} }
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold) if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
{ {
await riskFlagService.AddIfMissingAsync( await riskFlagService.AddIfMissingAsync(
category.SeasonId, season.Id,
submitterId, submitterId,
"nomination", "nomination",
"rapid_nomination_burst", "rapid_nomination_burst",
@@ -152,7 +180,7 @@ public static partial class PublicEndpoints
} }
await db.SaveChangesAsync(context.RequestAborted); 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); private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
@@ -191,4 +219,44 @@ public static partial class PublicEndpoints
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl)) .Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
.ToArray(); .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."); 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 var winnerPreviewRows = await db.Results
.AsNoTracking() .AsNoTracking()
.Include(result => result.Season) .Include(result => result.Season)
.Include(result => result.Candidate) .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) .OrderByDescending(result => result.Season.Year)
.ThenBy(result => result.CategoryName) .ThenBy(result => result.CategoryName)
.Take(8) .Take(8)
@@ -65,7 +71,9 @@ public static partial class PublicEndpoints
var archiveYearRows = await db.Results var archiveYearRows = await db.Results
.AsNoTracking() .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) .GroupBy(result => result.Season.Year)
.Select(group => new .Select(group => new
{ {
@@ -83,6 +91,33 @@ public static partial class PublicEndpoints
var publicCategories = season.Categories var publicCategories = season.Categories
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count)) .Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
.ToArray(); .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( var response = new OverviewResponse(
season.Id, season.Id,
season.Year, 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("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)), new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
}, },
publicCategories featuredCategories,
.Select(category => new FeaturedCategoryDto(
category.Id,
category.GroupName,
category.Name,
category.Description,
category.MaxNomineesPerUser))
.ToArray(),
winnerPreviewItems, winnerPreviewItems,
archiveYears, archiveYears,
new PublicSiteContentDto( new PublicSiteContentDto(
siteSettings.HostDisplayName, siteSettings.HostDisplayName,
siteSettings.HostTagline, siteSettings.HostTagline,
siteSettings.NewsletterUrl, siteSettings.NewsletterUrl,
siteSettings.ShareXUrl,
siteSettings.ShareDiscordUrl,
siteSettings.PrivacyEmail, siteSettings.PrivacyEmail,
siteSettings.PrivacyPolicyContent, siteSettings.PrivacyPolicyContent,
SeasonMappings.ReadSocialLinks(siteSettings), SeasonMappings.ReadSocialLinks(siteSettings),
@@ -124,11 +154,14 @@ public static partial class PublicEndpoints
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage) string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
? "Clip-Einreichungen sind aktuell geschlossen." ? "Clip-Einreichungen sind aktuell geschlossen."
: siteSettings.ClipSubmissionDisabledMessage, : siteSettings.ClipSubmissionDisabledMessage,
siteSettings.ShowactApplicationsEnabled, showactApplicationsOpenNow,
siteSettings.ShowactApplicationStartsAt,
siteSettings.ShowactApplicationEndsAt,
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage) string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen." ? "Showact-Bewerbungen sind aktuell geschlossen."
: siteSettings.ShowactApplicationDisabledMessage, : siteSettings.ShowactApplicationDisabledMessage,
siteSettings.SponsorsVisible), siteSettings.SponsorsVisible,
siteSettings.ShowactFormSchemaJson ?? "[]"),
SeasonMappings.ReadFaqItems(siteSettings)); SeasonMappings.ReadFaqItems(siteSettings));
return Results.Ok(response); return Results.Ok(response);
@@ -1,6 +1,7 @@
using Backend.Contracts; using Backend.Contracts;
using Backend.Data; using Backend.Data;
using Backend.Common; using Backend.Common;
using Backend.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints; namespace Backend.Endpoints;
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
} }
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase); var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
var publicCategories = season.Categories var publicCategories = season.Categories
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count)) .Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
.ToArray(); .ToArray();
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray(); var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
@@ -54,6 +57,8 @@ public static partial class PublicEndpoints
category.GroupName, category.GroupName,
category.Description, category.Description,
category.MaxNomineesPerUser, category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
category.Candidates category.Candidates
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase)) .Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
.Select(candidate => .Select(candidate =>
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration) private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
{ {
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings); var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
if (!usesDatabaseDemo) if (!usesDatabaseDemo)
{ {
return IsDemoLoginEnabled(configuration); return IsDemoLoginEnabled(configuration);
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
.Select(item => new .Select(item => new
{ {
item.CategoryId, item.CategoryId,
item.CategoryGroupName,
item.Status, item.Status,
Nominee = item.CandidateId != null Nominee = item.CandidateId != null
? item.Candidate!.DisplayName ? item.Candidate!.DisplayName
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
var groupedNominations = nominations var groupedNominations = nominations
.Where(item => item.Status != "rejected" && item.Status != "superseded") .Where(item => item.Status != "rejected" && item.Status != "superseded")
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee)) .Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
.GroupBy(item => item.CategoryId) .GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
.Select(group => new UserNominationStateDto( .Select(group => new UserNominationStateDto(
group.Key, group.Key.CategoryId,
group.Key.CategoryGroupName,
group.Select(item => item.Nominee!) group.Select(item => item.Nominee!)
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray())) .ToArray()))
@@ -26,6 +26,11 @@ public static class ServiceCollectionExtensions
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName)); services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
services.AddMemoryCache(); services.AddMemoryCache();
services.AddHttpClient(); services.AddHttpClient();
services.AddHttpClient("TwitchTracker", client =>
{
client.Timeout = TimeSpan.FromSeconds(4);
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
});
var allowedOrigins = ResolveAllowedOrigins(configuration, environment); var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres"); var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
@@ -88,6 +93,9 @@ public static class ServiceCollectionExtensions
services.AddScoped<IRiskRuleService, RiskRuleService>(); services.AddScoped<IRiskRuleService, RiskRuleService>();
services.AddScoped<IRiskFlagService, RiskFlagService>(); services.AddScoped<IRiskFlagService, RiskFlagService>();
services.AddScoped<IAdminAuditService, AdminAuditService>(); services.AddScoped<IAdminAuditService, AdminAuditService>();
services.AddScoped<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
services.AddScoped<NominationTrackingReviewService>();
services.AddScoped<NominationEnrichmentService>();
services.AddScoped<AdminSessionFilter>(); services.AddScoped<AdminSessionFilter>();
return services; 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> CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default);
Task<UserSession> CreateDevSessionAsync(LoginRequest request, 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> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default);
Task LogoutAsync(UserSession session, 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.Common;
using Backend.Contracts; using Backend.Contracts;
using Backend.Data;
using Backend.Domain; using Backend.Domain;
using Backend.Repositories; using Backend.Repositories;
using Backend.Security; using Backend.Security;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace Backend.Services; 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); private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default) public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
@@ -27,7 +30,8 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
} }
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
if (IsExpired(session, now)) var idleSessionLifetime = await ResolveIdleSessionLifetimeAsync(cancellationToken);
if (IsExpired(session, now, idleSessionLifetime))
{ {
session.IsActive = false; session.IsActive = false;
await userSessionRepository.SaveChangesAsync(cancellationToken); await userSessionRepository.SaveChangesAsync(cancellationToken);
@@ -40,6 +44,17 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
return session; 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) public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
{ {
return CreateSessionAsync( return CreateSessionAsync(
@@ -94,9 +109,18 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
: null; : 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.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime); || session.LastSeenAt <= now.Subtract(idleSessionLifetime);
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken) 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 MaxCandidateAppearances = "max_candidate_appearances";
public const string MaxWinnerPlacements = "max_winner_placements"; public const string MaxWinnerPlacements = "max_winner_placements";
public const string WinnerRequiresClip = "winner_requires_clip"; public const string WinnerRequiresClip = "winner_requires_clip";
public const string RecommendedNominatorsPerSubcategory = "recommended_nominators_per_subcategory";
public static WorkflowRuleSetting[] Defaults { get; } = 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(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(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(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) public static WorkflowRuleSetting[] Read(SiteSettings? settings)
{ {
var storedRules = Parse(settings?.WorkflowRulesJson); 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 return Defaults
.Select(defaultRule => .Select(defaultRule =>
{ {
@@ -98,6 +116,11 @@ public static class WorkflowRuleSettings
_ => fallback.Mode, _ => fallback.Mode,
}; };
if (string.Equals(fallback.Key, RecommendedNominatorsPerSubcategory, StringComparison.OrdinalIgnoreCase))
{
mode = "warn";
}
return rule with return rule with
{ {
Key = fallback.Key, Key = fallback.Key,
+5
View File
@@ -3,6 +3,8 @@ import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import AppShellAccountModals from './AppShellAccountModals.vue' import AppShellAccountModals from './AppShellAccountModals.vue'
import AdminToastViewport from './admin/AdminToastViewport.vue'
import { useAdminApiDisconnectToast } from '../composables/useAdminApiDisconnectToast'
import { useBodyScrollLock } from '../composables/useBodyScrollLock' import { useBodyScrollLock } from '../composables/useBodyScrollLock'
import { privacyContentToHtml } from '../lib/privacyContent' import { privacyContentToHtml } from '../lib/privacyContent'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -35,6 +37,7 @@ const navItems = [
] ]
const visibleNavItems = computed(() => navItems) const visibleNavItems = computed(() => navItems)
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
const privacyContent = computed( const privacyContent = computed(
() => awardsStore.overview.siteContent.privacyPolicyContent || awardsStore.adminSiteSettings.privacyPolicyContent, () => awardsStore.overview.siteContent.privacyPolicyContent || awardsStore.adminSiteSettings.privacyPolicyContent,
) )
@@ -140,6 +143,7 @@ const linkBase = 'padding:9px 14px;border-radius:9px;font-family:\'Outfit\',sans
const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;' const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;' const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
useAdminApiDisconnectToast(isAdminRoute)
</script> </script>
<template> <template>
@@ -231,6 +235,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
/> />
</template> </template>
<AdminToastViewport />
</div> </div>
</template> </template>
@@ -31,6 +31,41 @@
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" /> <input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
</label> </label>
<section v-if="identitySummary || ruleNotices.length" class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
<div class="mb-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Regel-Kontext</p>
<p class="mt-1 text-sm text-slate-500">Diese Werte helfen bei mehrfachen Nominierungen und Gewinnergrenzen.</p>
</div>
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-4">
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Kandidaturen</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.appearances }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Angenommen</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.acceptedAppearances }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Gewinner</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.winnerPlacements }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Viewer-Nominierungen</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.nominationTally }}</strong>
</div>
</div>
<div v-if="ruleNotices.length" class="mt-3 space-y-2">
<p
v-for="notice in ruleNotices"
:key="notice.message"
class="rounded-2xl border px-4 py-3 text-sm font-semibold"
:class="notice.mode === 'block' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-amber-100 bg-amber-50 text-amber-700'"
>
{{ notice.mode === 'block' ? 'Blockiert' : 'Warnung' }}: {{ notice.message }}
</p>
</div>
</section>
<section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4"> <section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
<div class="mb-3"> <div class="mb-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p>
@@ -128,6 +163,13 @@ defineProps<{
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }> acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
clipEmbedStatusOptions: Array<{ label: string; value: string }> clipEmbedStatusOptions: Array<{ label: string; value: string }>
selectedPlatformValue: string selectedPlatformValue: string
identitySummary: {
appearances: number
acceptedAppearances: number
winnerPlacements: number
nominationTally: number
} | null
ruleNotices: Array<{ mode: 'warn' | 'block'; message: string }>
canSave: boolean canSave: boolean
saving: boolean saving: boolean
}>() }>()
@@ -22,6 +22,13 @@
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p> <p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p> <p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
<p class="mt-1 text-xs text-slate-500">
{{ candidate.nominationTally }} Viewer-Nominierungen
<template v-if="candidateIdentitySummaries[identityKey(candidate)]">
· {{ candidateIdentitySummaries[identityKey(candidate)].appearances }} Kandidaturen
· {{ candidateIdentitySummaries[identityKey(candidate)].winnerPlacements }} Gewinnerplätze
</template>
</p>
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700"> <p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat in dieser Kategorie Mögliches Duplikat in dieser Kategorie
</p> </p>
@@ -122,6 +129,7 @@ const props = defineProps<{
rangeEnd: number rangeEnd: number
categoryLabelMap: Record<number, string> categoryLabelMap: Record<number, string>
duplicateCandidateKeys: Map<string, number> duplicateCandidateKeys: Map<string, number>
candidateIdentitySummaries: Record<string, { appearances: number; acceptedAppearances: number; winnerPlacements: number; nominationTally: number }>
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>> candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }> acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
}>() }>()
@@ -138,6 +146,15 @@ function isDuplicate(candidate: AdminCandidateItem) {
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1 || (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
} }
function identityKey(candidate: AdminCandidateItem) {
if (typeof candidate.streamerIdentityId === 'number') {
return `identity:${candidate.streamerIdentityId}`
}
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function acceptanceLabel(value: string) { function acceptanceLabel(value: string) {
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen' return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
} }
@@ -0,0 +1,109 @@
<template>
<Modal
:open="open"
size="lg"
:title="title"
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewaehlten Season."
@close="$emit('close')"
>
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
:value="form.groupName"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Gaming"
@input="form.groupName = ($event.target as HTMLInputElement).value"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input
:value="form.sortOrder"
type="number"
min="1"
max="200"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@input="form.sortOrder = toPositiveInteger(($event.target as HTMLInputElement).value, 1)"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierungs-Limit</span>
<input
:value="form.maxNomineesPerUser"
type="number"
min="1"
max="10"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@input="form.maxNomineesPerUser = toPositiveInteger(($event.target as HTMLInputElement).value, 3)"
/>
</label>
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
:value="form.description"
class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Kurze Orientierung fuer Admins und Public-Flows"
@input="form.description = ($event.target as HTMLTextAreaElement).value"
/>
</label>
</div>
<p v-if="!canCreateGroup && !isEditing" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Lege zuerst globale Unterkategorien an, bevor du neue Hauptkategorien erzeugst.
</p>
<template #footer>
<Button
v-if="isEditing"
variant="ghost"
class="mr-auto gap-2 border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100"
@click="$emit('delete')"
>
<Trash2 class="h-4 w-4" />
Loeschen
</Button>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
<Button :disabled="!canSave || saving" @click="$emit('save')">
{{ saving ? 'Speichert ...' : isEditing ? 'Hauptkategorie speichern' : 'Hauptkategorie anlegen' }}
</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
defineProps<{
open: boolean
title: string
form: {
groupName: string
description: string
sortOrder: number
maxNomineesPerUser: number
}
canSave: boolean
canCreateGroup: boolean
saving: boolean
isEditing: boolean
}>()
defineEmits<{
close: []
save: []
delete: []
}>()
function toPositiveInteger(value: string, fallback: number) {
const number = Number(value)
return Number.isFinite(number) ? Math.max(1, Math.round(number)) : fallback
}
</script>
@@ -0,0 +1,394 @@
<template>
<Modal :open="open" size="xl" :title="title" :subtitle="subtitle" @close="$emit('close')">
<div class="grid gap-4 md:grid-cols-4">
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-500">Unterkategorien</p>
<strong class="mt-2 block text-2xl text-violet-900">{{ categories.length }}</strong>
</div>
<div class="rounded-2xl border border-emerald-100 bg-emerald-50/70 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-600">Kandidaten</p>
<strong class="mt-2 block text-2xl text-emerald-800">{{ candidates.length }}</strong>
</div>
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600">Nominierungen</p>
<strong class="mt-2 block text-2xl text-amber-800">{{ nominations.length }}</strong>
</div>
<div class="rounded-2xl border border-sky-100 bg-sky-50/70 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-sky-600">Nominierer</p>
<strong class="mt-2 block text-2xl text-sky-800">{{ totalNominatorCount }}</strong>
</div>
</div>
<div v-if="readinessItems.length > 0" class="mt-4 grid gap-2">
<article
v-for="item in readinessItems"
:key="item.category.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3"
:class="item.isReady ? 'border-emerald-100 bg-emerald-50/70' : 'border-sky-100 bg-sky-50/70'"
>
<div class="min-w-0">
<p class="truncate text-sm font-semibold" :class="item.isReady ? 'text-emerald-900' : 'text-sky-900'">
{{ item.category.groupName }} · {{ item.category.name }}
</p>
<p class="mt-1 text-xs" :class="item.isReady ? 'text-emerald-700' : 'text-sky-700'">
{{ item.nominatorCount }} von {{ readinessTargetLabel }} Nominierern erreicht. Hinweis, kein Blocker.
</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.isReady ? 'bg-emerald-100 text-emerald-700' : 'bg-white/80 text-sky-700'">
{{ item.isReady ? 'Bereit' : 'Aufbau' }}
</span>
</article>
</div>
<div class="mt-5 grid gap-5 lg:grid-cols-[0.82fr_1.18fr]">
<section class="space-y-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Tree-Ausschnitt</p>
<p class="mt-1 text-sm text-slate-500">Aktuelle Struktur und Belegung.</p>
</div>
<article
v-for="category in categories"
:key="category.id"
class="rounded-2xl border border-violet-100 bg-white px-4 py-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
<p class="mt-1 font-mono text-xs text-slate-400">/{{ category.slug }}</p>
</div>
<span class="shrink-0 rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}
</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">
{{ category.candidateCount }} Kandidaten
</span>
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">
{{ category.pendingCount }} Reviews
</span>
<span class="rounded-full bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
{{ category.nominatorCount }}/{{ readinessTargetLabel }} Nominierer
</span>
</div>
</article>
</section>
<section class="space-y-5">
<div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Nominierungs-Leaderboard</p>
<p class="mt-1 text-sm text-slate-500">Standard: viele Nominierungen vor wenigen Nominierungen.</p>
</div>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
{{ filteredLeaderboardRows.length }} Einträge
</span>
</div>
<div class="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
<label class="block">
<span class="sr-only">Leaderboard filtern</span>
<input
v-model="leaderboardQuery"
class="h-10 w-full rounded-2xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Streamer, Link oder Plattform filtern"
/>
</label>
<select
v-model="leaderboardStatusFilter"
class="h-10 rounded-2xl border border-violet-200 bg-white px-3 text-sm font-semibold text-slate-600 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
>
<option value="all">Alle Status</option>
<option value="pending">Offen</option>
<option value="approved">Übernommen</option>
<option value="rejected">Verworfen</option>
<option value="candidate">Kandidaten</option>
</select>
<select
v-model="leaderboardSort"
class="h-10 rounded-2xl border border-violet-200 bg-white px-3 text-sm font-semibold text-slate-600 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
>
<option value="votes-desc">Viele Votes</option>
<option value="votes-asc">Wenige Votes</option>
<option value="name-asc">Name A-Z</option>
<option value="recent-desc">Neueste</option>
</select>
</div>
</div>
<div v-if="filteredLeaderboardRows.length > 0" class="grid gap-2">
<article
v-for="(row, index) in filteredLeaderboardRows"
:key="row.key"
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-sm font-black text-violet-700">
#{{ index + 1 }}
</span>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ row.label }}</p>
<p class="mt-1 truncate text-xs text-slate-500">
{{ row.platform || 'Plattform offen' }} · {{ row.url || 'kein Link' }}
</p>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="status in row.statuses"
:key="`${row.key}-${status}`"
class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]"
:class="statusBadgeClass(status)"
>
{{ statusLabel(status) }}
</span>
</div>
</div>
<div class="sm:text-right">
<strong class="block text-2xl text-violet-900">{{ row.voteCount }}</strong>
<span class="text-[11px] font-semibold uppercase tracking-[0.14em] text-violet-500">Nominierer</span>
<p class="mt-1 text-xs text-slate-500">{{ row.nominationCount }} Einreichungen</p>
</div>
</div>
</article>
</div>
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
Keine Leaderboard-Einträge passen zum Filter.
</p>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Kandidaten</p>
<p class="mt-1 text-sm text-slate-500">Finale und vorbereitete Kandidaten in diesem Ausschnitt.</p>
</div>
<div v-if="candidates.length > 0" class="grid gap-2">
<article
v-for="candidate in candidates"
:key="candidate.id"
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ candidate.displayName }}</p>
<p class="mt-1 truncate text-xs text-slate-500">{{ candidate.platform }} · {{ candidate.channelSlug }}</p>
</div>
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
{{ candidate.nominationTally }}x
</span>
</div>
</article>
</div>
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
Noch keine Kandidaten in diesem Ausschnitt.
</p>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Roh-Nominierungen</p>
<p class="mt-1 text-sm text-slate-500">Offene und bereits gepruefte Einreichungen zur Kontrolle.</p>
</div>
<div v-if="nominations.length > 0" class="grid gap-2">
<article
v-for="nomination in nominations"
:key="nomination.id"
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ nomination.resolvedChannel || nomination.candidateText || 'Ohne Namen' }}</p>
<p class="mt-1 truncate text-xs text-slate-500">{{ nomination.streamUrl || nomination.categoryGroupName }}</p>
</div>
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="nomination.status === 'pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-600'">
{{ nomination.status }}
</span>
</div>
</article>
</div>
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
Keine Nominierungen in diesem Ausschnitt.
</p>
</section>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Schliessen</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import type { AdminCategoryGroupSummary } from './useAdminCategoryManager'
import { formatViewerRange } from './useAdminCategoryManager'
const props = defineProps<{
open: boolean
title: string
subtitle: string
categories: AdminCategoryGroupSummary['categories']
candidates: AdminCandidateItem[]
nominations: AdminNominationReviewItem[]
recommendedNominatorTarget: number | null
}>()
defineEmits<{
close: []
}>()
type LeaderboardStatus = 'pending' | 'approved' | 'rejected' | 'candidate' | string
type LeaderboardStatusFilter = 'all' | 'pending' | 'approved' | 'rejected' | 'candidate'
type LeaderboardSort = 'votes-desc' | 'votes-asc' | 'name-asc' | 'recent-desc'
interface LeaderboardRow {
key: string
label: string
platform: string
url: string
voteCount: number
nominationCount: number
latestCreatedAt: string
statuses: LeaderboardStatus[]
nominatorKeys: string[]
searchText: string
}
const leaderboardQuery = ref('')
const leaderboardStatusFilter = ref<LeaderboardStatusFilter>('all')
const leaderboardSort = ref<LeaderboardSort>('votes-desc')
const totalNominatorCount = computed(() => uniqueNominatorCount(props.nominations))
const readinessTargetLabel = computed(() => props.recommendedNominatorTarget === null ? '' : String(props.recommendedNominatorTarget))
const readinessItems = computed(() =>
props.recommendedNominatorTarget === null
? []
: props.categories.map((category) => {
const target = props.recommendedNominatorTarget ?? 0
const nominations = props.nominations.filter((nomination) =>
(nomination.categoryId !== null && nomination.categoryId === category.id)
|| (nomination.suggestedCategoryId !== null && nomination.suggestedCategoryId === category.id),
)
const nominatorCount = uniqueNominatorCount(nominations)
return {
category,
nominatorCount,
isReady: nominatorCount >= target,
}
}),
)
const leaderboardRows = computed<LeaderboardRow[]>(() => {
const rows = new Map<string, LeaderboardRow>()
for (const nomination of props.nominations) {
const key = nominationKey(nomination)
const existing = rows.get(key)
const label = nomination.resolvedChannel || nomination.candidateDisplayName || nomination.candidateText || nomination.streamUrl || 'Ohne Namen'
const platform = nomination.resolvedPlatform || ''
const url = nomination.streamUrl || ''
const status = nomination.status || 'pending'
const createdAt = nomination.createdAt || ''
const row = existing ?? {
key,
label,
platform,
url,
voteCount: 0,
nominationCount: 0,
latestCreatedAt: createdAt,
statuses: [],
nominatorKeys: [],
searchText: '',
}
row.nominationCount += 1
const nominatorKey = nomination.submittedByTwitchId.trim().toLowerCase()
if (nominatorKey && !row.nominatorKeys.includes(nominatorKey)) {
row.nominatorKeys.push(nominatorKey)
}
row.voteCount = Math.max(row.voteCount, row.nominatorKeys.length)
if (!row.statuses.includes(status)) row.statuses.push(status)
if (createdAt && (!row.latestCreatedAt || createdAt > row.latestCreatedAt)) row.latestCreatedAt = createdAt
row.searchText = [row.label, row.platform, row.url, row.statuses.join(' ')].join(' ').toLowerCase()
rows.set(key, row)
}
for (const candidate of props.candidates) {
const key = candidateKey(candidate)
const existing = rows.get(key)
const row = existing ?? {
key,
label: candidate.displayName,
platform: candidate.platform,
url: candidate.channelSlug,
voteCount: 0,
nominationCount: 0,
latestCreatedAt: '',
statuses: [],
nominatorKeys: [],
searchText: '',
}
row.label = row.label || candidate.displayName
row.platform = row.platform || candidate.platform
row.url = row.url || candidate.channelSlug
row.voteCount = Math.max(row.voteCount, candidate.nominationTally)
if (!row.statuses.includes('candidate')) row.statuses.push('candidate')
row.searchText = [row.label, row.platform, row.url, row.statuses.join(' ')].join(' ').toLowerCase()
rows.set(key, row)
}
return [...rows.values()]
})
const filteredLeaderboardRows = computed(() => {
const search = leaderboardQuery.value.trim().toLowerCase()
const statusFilter = leaderboardStatusFilter.value
const filtered = leaderboardRows.value.filter((row) =>
(!search || row.searchText.includes(search))
&& (statusFilter === 'all' || row.statuses.includes(statusFilter)),
)
return filtered.sort((left, right) => {
if (leaderboardSort.value === 'votes-asc') return left.voteCount - right.voteCount || left.nominationCount - right.nominationCount || left.label.localeCompare(right.label, 'de')
if (leaderboardSort.value === 'name-asc') return left.label.localeCompare(right.label, 'de')
if (leaderboardSort.value === 'recent-desc') return right.latestCreatedAt.localeCompare(left.latestCreatedAt) || right.voteCount - left.voteCount || right.nominationCount - left.nominationCount
return right.voteCount - left.voteCount || right.nominationCount - left.nominationCount || left.label.localeCompare(right.label, 'de')
})
})
function nominationKey(nomination: AdminNominationReviewItem) {
if (nomination.streamerIdentityId !== null) return `identity:${nomination.streamerIdentityId}`
const channel = (nomination.resolvedChannel || nomination.candidateText || '').trim().toLowerCase()
if (channel) return `channel:${channel}`
const url = (nomination.streamUrl || '').trim().toLowerCase()
if (url) return `url:${url}`
return `nomination:${nomination.id}`
}
function candidateKey(candidate: AdminCandidateItem) {
if (candidate.streamerIdentityId !== null) return `identity:${candidate.streamerIdentityId}`
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
if (channel) return `channel:${channel}`
return `candidate:${candidate.id}`
}
function uniqueNominatorCount(nominations: AdminNominationReviewItem[]) {
return new Set(nominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean)).size
}
function statusLabel(status: LeaderboardStatus) {
if (status === 'pending') return 'offen'
if (status === 'approved') return 'übernommen'
if (status === 'rejected') return 'verworfen'
if (status === 'candidate') return 'Kandidat'
return status
}
function statusBadgeClass(status: LeaderboardStatus) {
if (status === 'pending') return 'bg-amber-50 text-amber-700'
if (status === 'approved' || status === 'candidate') return 'bg-emerald-50 text-emerald-700'
if (status === 'rejected') return 'bg-rose-50 text-rose-700'
return 'bg-slate-100 text-slate-600'
}
</script>
@@ -0,0 +1,98 @@
<template>
<Modal :open="open" size="lg" :title="title" subtitle="Diese Unterkategorie wird spaeter bei allen Hauptkategorien verwendet." @close="$emit('close')">
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
:value="form.name"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Rising Star"
@input="form.name = ($event.target as HTMLInputElement).value"
/>
</label>
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<div class="flex gap-2">
<input
:value="form.slug"
type="text"
class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="rising-star"
@input="form.slug = ($event.target as HTMLInputElement).value"
/>
<Button variant="ghost" class="border border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100" @click="$emit('fill-slug')">
Auto
</Button>
</div>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer von</span>
<input
:value="form.viewerRangeMin ?? ''"
type="number"
min="0"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="0"
@input="form.viewerRangeMin = toNullableNumber(($event.target as HTMLInputElement).value)"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer bis</span>
<input
:value="form.viewerRangeMax ?? ''"
type="number"
min="0"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="offen"
@input="form.viewerRangeMax = toNullableNumber(($event.target as HTMLInputElement).value)"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input
:value="form.sortOrder"
type="number"
min="1"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@input="form.sortOrder = Math.max(1, Number(($event.target as HTMLInputElement).value) || 1)"
/>
</label>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
<Button :disabled="!canSave" @click="$emit('save')">Unterkategorie speichern</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import type { AdminSubcategoryDraft } from './useAdminCategoryManager'
defineProps<{
open: boolean
title: string
form: AdminSubcategoryDraft
canSave: boolean
}>()
defineEmits<{
close: []
save: []
'fill-slug': []
}>()
function toNullableNumber(value: string) {
const trimmed = value.trim()
if (!trimmed) return null
const number = Number(trimmed)
return Number.isFinite(number) ? number : null
}
</script>
@@ -0,0 +1,193 @@
<template>
<Modal
:open="open"
size="xl"
title="Unterkategorien konfigurieren"
subtitle="Diese Viewer-Sektionen gelten fuer alle Hauptkategorien der ausgewaehlten Season."
@close="$emit('close')"
>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
<section class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-slate-900">Globale Unterkategorien</p>
<p class="text-sm text-slate-500">Die Hauptkategorien muessen sie nicht einzeln anlegen.</p>
</div>
<Button class="gap-2" @click="$emit('add-subcategory')">
<PlusCircle class="h-4 w-4" />
Unterkategorie
</Button>
</div>
<div v-if="subcategories.length > 0" class="grid gap-3">
<article
v-for="(subcategory, index) in subcategories"
:key="`${subcategory.slug}-${index}`"
class="rounded-2xl border border-violet-100 bg-white px-4 py-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-base font-bold text-slate-900">{{ subcategory.name || 'Neue Unterkategorie' }}</h3>
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
{{ formatViewerRange(subcategory.viewerRangeMin, subcategory.viewerRangeMax) }}
</span>
</div>
<p class="mt-1 font-mono text-xs text-slate-400">/{{ subcategory.slug || 'slug-fehlt' }}</p>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-600">
#{{ subcategory.sortOrder }}
</span>
<Button variant="ghost" size="sm" class="gap-2" @click="$emit('edit-subcategory', index)">
<Pencil class="h-4 w-4" />
Bearbeiten
</Button>
<Button variant="ghost" size="sm" class="gap-2 border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100" @click="$emit('remove-subcategory', index)">
<Trash2 class="h-4 w-4" />
Entfernen
</Button>
</div>
</div>
</article>
</div>
<div v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-8 text-center text-sm text-slate-500">
Noch keine Unterkategorien vorhanden.
</div>
</section>
<aside class="rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
<div v-if="editorOpen" class="space-y-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ editorTitle }}</p>
<p class="mt-1 text-sm text-slate-500">Aenderungen werden erst mit der gesamten Unterkategorien-Konfiguration gespeichert.</p>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
:value="form.name"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Rising Star"
@input="form.name = ($event.target as HTMLInputElement).value"
/>
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<div class="flex gap-2">
<input
:value="form.slug"
type="text"
class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="rising-star"
@input="form.slug = ($event.target as HTMLInputElement).value"
/>
<Button variant="ghost" class="border border-violet-200 bg-white text-violet-700 hover:bg-violet-50" @click="$emit('fill-slug')">
Auto
</Button>
</div>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer von</span>
<input
:value="form.viewerRangeMin ?? ''"
type="number"
min="0"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="0"
@input="form.viewerRangeMin = toNullableNumber(($event.target as HTMLInputElement).value)"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer bis</span>
<input
:value="form.viewerRangeMax ?? ''"
type="number"
min="0"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="offen"
@input="form.viewerRangeMax = toNullableNumber(($event.target as HTMLInputElement).value)"
/>
</label>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input
:value="form.sortOrder"
type="number"
min="1"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@input="form.sortOrder = Math.max(1, Number(($event.target as HTMLInputElement).value) || 1)"
/>
</label>
<div class="flex justify-end gap-2">
<Button variant="ghost" @click="$emit('close-editor')">Abbrechen</Button>
<Button :disabled="!canSaveSubcategory" @click="$emit('save-subcategory')">
Unterkategorie uebernehmen
</Button>
</div>
</div>
<div v-else class="flex min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed border-violet-200 bg-white/60 px-4 py-8 text-center">
<Layers3 class="h-8 w-8 text-violet-300" />
<p class="mt-3 font-semibold text-slate-700">Unterkategorie auswaehlen</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Bearbeite eine bestehende Sektion oder lege eine neue Viewer-Sektion an.</p>
</div>
</aside>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
<Button :disabled="!canSave || saving" @click="$emit('save')">
{{ saving ? 'Speichert ...' : 'Unterkategorien speichern' }}
</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { Layers3, Pencil, PlusCircle, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import type { AdminSubcategoryDraft } from './useAdminCategoryManager'
import { formatViewerRange } from './useAdminCategoryManager'
defineProps<{
open: boolean
subcategories: AdminSubcategoryDraft[]
canSave: boolean
saving: boolean
editorOpen: boolean
editorTitle: string
form: AdminSubcategoryDraft
canSaveSubcategory: boolean
}>()
defineEmits<{
close: []
save: []
'add-subcategory': []
'edit-subcategory': [index: number]
'remove-subcategory': [index: number]
'close-editor': []
'save-subcategory': []
'fill-slug': []
}>()
function toNullableNumber(value: string) {
const trimmed = value.trim()
if (!trimmed) return null
const number = Number(trimmed)
return Number.isFinite(number) ? number : null
}
</script>
@@ -0,0 +1,140 @@
<template>
<div class="space-y-3">
<article
v-for="group in groups"
:key="group.name"
class="overflow-hidden rounded-2xl border bg-white/90 shadow-[0_14px_36px_rgba(124,91,180,0.08)]"
:class="selectedGroupName === group.name ? 'border-violet-200 ring-2 ring-violet-100' : 'border-violet-100'"
>
<div class="grid gap-3 border-b border-violet-100 bg-violet-50/45 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<button type="button" class="min-w-0 text-left" @click="$emit('edit-group', group)">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-white text-violet-700 shadow-sm">
<FolderTree class="h-4 w-4" />
</span>
<h2 class="truncate text-lg font-bold text-slate-900">{{ group.name }}</h2>
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">
#{{ group.sortOrder }}
</span>
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-600">
Limit {{ group.maxNomineesPerUser }}
</span>
</div>
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">
{{ group.description || 'Keine Beschreibung hinterlegt.' }}
</p>
</button>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
{{ group.candidateCount }} Kandidaten
</span>
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
{{ group.pendingCount }} Reviews
</span>
<span class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
{{ group.nominatorCount }} Nominierer
</span>
<Button variant="ghost" size="sm" class="gap-2" @click.stop="$emit('overview-group', group)">
<Info class="h-4 w-4" />
Details
</Button>
<Button size="sm" class="gap-2" @click.stop="$emit('edit-group', group)">
<Pencil class="h-4 w-4" />
Bearbeiten
</Button>
</div>
</div>
<div class="divide-y divide-violet-100">
<div
v-for="category in group.categories"
:key="category.id"
class="grid gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"
:class="isFocused(category.slug) || isFocused(category.name) ? 'bg-sky-50/60' : 'bg-white'"
>
<button type="button" class="min-w-0 border-l-2 border-violet-100 pl-4 text-left" @click="$emit('overview-category', group, category)">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-violet-50 text-violet-600">
<GitBranch class="h-3.5 w-3.5" />
</span>
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
<span
v-if="isFocused(category.slug) || isFocused(category.name)"
class="rounded-full bg-sky-100 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700"
>
Fokus
</span>
<span class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="statusClass(category)">
{{ statusLabel(category) }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">
<span class="font-mono text-slate-400">/{{ category.slug }}</span>
<span>{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}</span>
</div>
</button>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
{{ category.candidateCount }} Kandidaten
</span>
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
{{ category.pendingCount }} Reviews
</span>
<span class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
{{ category.nominatorCount }} Nominierer<span v-if="recommendedNominatorTarget !== null"> · Ziel {{ recommendedNominatorTarget }}</span>
</span>
<Button variant="ghost" size="sm" class="gap-2" @click="$emit('overview-category', group, category)">
<Info class="h-4 w-4" />
Overview
</Button>
</div>
</div>
</div>
</article>
<div v-if="groups.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/50 px-4 py-12 text-center text-sm text-slate-500">
Keine Hauptkategorien passen zu diesem Filter.
</div>
</div>
</template>
<script setup lang="ts">
import { FolderTree, GitBranch, Info, Pencil } from '@lucide/vue'
import Button from '../ui/Button.vue'
import type { AdminCategoryGroupSummary } from './useAdminCategoryManager'
import { formatViewerRange } from './useAdminCategoryManager'
type TreeCategory = AdminCategoryGroupSummary['categories'][number]
defineProps<{
groups: AdminCategoryGroupSummary[]
selectedGroupName: string | null
isFocused: (nameOrSlug: string) => boolean
recommendedNominatorTarget: number | null
}>()
defineEmits<{
'edit-group': [group: AdminCategoryGroupSummary]
'overview-group': [group: AdminCategoryGroupSummary]
'overview-category': [group: AdminCategoryGroupSummary, category: TreeCategory]
}>()
function statusLabel(category: TreeCategory) {
if (category.pendingCount > 0) return 'Reviews'
if (category.nominatorCount >= 4) return 'Bereit'
if (category.nominatorCount > 0 || category.candidateCount > 0) return 'Aufbau'
if (category.candidateCount === 0) return 'Leer'
return 'Aufbau'
}
function statusClass(category: TreeCategory) {
if (category.pendingCount > 0) return 'bg-amber-50 text-amber-700'
if (category.nominatorCount >= 4) return 'bg-emerald-50 text-emerald-700'
if (category.nominatorCount > 0 || category.candidateCount > 0) return 'bg-sky-50 text-sky-700'
if (category.candidateCount === 0) return 'bg-rose-50 text-rose-700'
return 'bg-sky-50 text-sky-700'
}
</script>
@@ -29,6 +29,14 @@
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter URL</span> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter URL</span>
<input v-model="form.newsletterUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" /> <input v-model="form.newsletterUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label> </label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Auf X teilen URL</span>
<input v-model="form.shareXUrl" type="url" placeholder="https://x.com/intent/tweet?..." class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Auf Discord teilen URL</span>
<input v-model="form.shareDiscordUrl" type="url" placeholder="https://discord.gg/..." class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
</div> </div>
</Card> </Card>
</template> </template>
@@ -1,22 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue' import { Eye, Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards' import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
import Button from '../ui/Button.vue' import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue' import AdminContentFooterPreviewModal from './AdminContentFooterPreviewModal.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue' import AdminSettingsToggle from './AdminSettingsToggle.vue'
import type { AdminContentForm } from './adminContentTypes'
import AdminShowactFormBuilder from './AdminShowactFormBuilder.vue'
import Modal from '../ui/Modal.vue'
const props = defineProps<{
section: 'showacts' | 'sponsors'
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
const store = useAwardsStore() const store = useAwardsStore()
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const localSaving = ref(false)
const statusMessage = ref('') const statusMessage = ref('')
const statusError = ref('') const statusError = ref('')
const previewOpen = ref(false)
const previewHtml = computed(() => privacyContentToHtml(props.form.sponsorsContent))
const previewTitle = 'Sponsoren & Partner'
const editingSponsorId = ref<number | null>(null) const editingSponsorId = ref<number | null>(null)
const selectedShowactId = ref<number | null>(null)
const showactNotes = reactive<Record<number, string>>({}) const showactNotes = reactive<Record<number, string>>({})
const settingsForm = reactive({ const settingsForm = reactive({
showactApplicationsEnabled: false, showactApplicationsEnabled: false,
showactApplicationStartsAt: '',
showactApplicationEndsAt: '',
showactApplicationsOpenNow: false,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.', showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true, sponsorsVisible: true,
}) })
@@ -33,17 +52,108 @@ const sponsorForm = reactive<UpsertSponsorPayload>({
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId)) const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length) const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length)
const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length) const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length)
const selectedShowact = computed(() =>
selectedShowactId.value == null
? null
: store.adminShowactApplications.find((item) => item.id === selectedShowactId.value) ?? null,
)
const showactSubmissionDetails = computed(() => {
const application = selectedShowact.value
if (!application) return []
const schemaFields = props.form.showactFormSchema ?? []
const responseEntries = parseShowactResponses(application.fieldResponsesJson)
const responseMap = new Map(responseEntries)
const consumedKeys = new Set<string>()
const orderedFields = schemaFields
.map((field) => {
const value = normalizeDetailValue(responseMap.get(field.id))
consumedKeys.add(field.id)
if (!value) return null
return {
key: field.id,
label: field.label,
value,
}
})
.filter((item): item is { key: string; label: string; value: string } => item !== null)
const remainingFields = responseEntries
.filter(([key]) => !consumedKeys.has(key))
.map(([key, value]) => ({
key,
label: humanizeShowactKey(key),
value: normalizeDetailValue(value),
}))
.filter((item) => item.value)
if (orderedFields.length > 0 || remainingFields.length > 0) {
return [...orderedFields, ...remainingFields]
}
return [
{ key: 'artistName', label: 'Artist', value: application.artistName },
{ key: 'performanceType', label: 'Performance', value: application.performanceType },
{ key: 'description', label: 'Beschreibung', value: application.description },
{ key: 'technicalNotes', label: 'Technische Hinweise', value: application.technicalNotes },
{ key: 'platformUrl', label: 'Kanal oder Profil', value: application.platformUrl },
{ key: 'referenceUrl', label: 'Referenz', value: application.referenceUrl },
{ key: 'contactEmail', label: 'E-Mail', value: application.contactEmail },
{ key: 'contactDiscord', label: 'Discord', value: application.contactDiscord },
].filter((item) => normalizeDetailValue(item.value))
})
watch( watch(
() => store.adminOptionalFeatureSettings, () => store.adminOptionalFeatureSettings,
(settings) => { (settings) => {
settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled
settingsForm.showactApplicationStartsAt = settings.showactApplicationStartsAt ?? ''
settingsForm.showactApplicationEndsAt = settings.showactApplicationEndsAt ?? ''
settingsForm.showactApplicationsOpenNow = settings.showactApplicationsOpenNow
settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.' settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.'
settingsForm.sponsorsVisible = settings.sponsorsVisible settingsForm.sponsorsVisible = settings.sponsorsVisible
}, },
{ immediate: true, deep: true }, { immediate: true, deep: true },
) )
const showactStatusText = computed(() => {
if (!settingsForm.showactApplicationsEnabled) {
return 'Bewerbungen sind manuell deaktiviert und es wird nur der Hinweistext ausgespielt.'
}
const parts: string[] = []
if (settingsForm.showactApplicationStartsAt) {
parts.push(`Start ${formatDate(settingsForm.showactApplicationStartsAt)}`)
}
if (settingsForm.showactApplicationEndsAt) {
parts.push(`Deadline ${formatDate(settingsForm.showactApplicationEndsAt)}`)
}
if (settingsForm.showactApplicationsOpenNow) {
return parts.length > 0
? `Bewerbungen sind aktuell offen. ${parts.join(' · ')}.`
: 'Bewerbungen sind aktuell offen und das Modal bleibt fuer Einreichungen verfuegbar.'
}
if (parts.length > 0) {
return `Bewerbungen sind aktuell geschlossen. ${parts.join(' · ')}.`
}
return 'Bewerbungen sind aktuell geschlossen und es wird nur der Hinweistext ausgespielt.'
})
const showactScheduleError = computed(() => {
if (!settingsForm.showactApplicationStartsAt || !settingsForm.showactApplicationEndsAt) {
return ''
}
return settingsForm.showactApplicationStartsAt > settingsForm.showactApplicationEndsAt
? 'Der Starttermin darf nicht nach der Deadline liegen.'
: ''
})
function resetSponsorForm() { function resetSponsorForm() {
editingSponsorId.value = null editingSponsorId.value = null
sponsorForm.name = '' sponsorForm.name = ''
@@ -92,31 +202,41 @@ async function loadLandingExtras() {
} }
} }
async function saveLandingExtrasSettings() { async function saveAll() {
saving.value = true localSaving.value = true
statusMessage.value = '' statusMessage.value = ''
statusError.value = '' statusError.value = ''
try { try {
await store.updateAdminOptionalFeatureSettings({ if (showactScheduleError.value) {
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled, throw new Error(showactScheduleError.value)
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled, }
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled, await Promise.all([
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage, store.updateAdminOptionalFeatureSettings({
sponsorsVisible: settingsForm.sponsorsVisible, clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
}) clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
statusMessage.value = 'Landingpage-Extras wurden gespeichert.' clipAdminMenuVisible: store.adminOptionalFeatureSettings.clipAdminMenuVisible,
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
showactApplicationStartsAt: settingsForm.showactApplicationStartsAt || null,
showactApplicationEndsAt: settingsForm.showactApplicationEndsAt || null,
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
sponsorsVisible: settingsForm.sponsorsVisible,
}),
props.saveSiteSettings(props.section === 'showacts' ? 'Showact-Inhalt' : 'Sponsoren-Inhalt'),
])
statusMessage.value = 'Einstellungen gespeichert.'
} catch (error) { } catch (error) {
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht gespeichert werden.' statusError.value = error instanceof Error ? error.message : 'Einstellungen konnten nicht gespeichert werden.'
} finally { } finally {
saving.value = false localSaving.value = false
} }
} }
async function saveSponsor() { async function saveSponsor() {
const resolvedSeasonId = await ensureSeasonId() const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return if (!resolvedSeasonId) return
saving.value = true localSaving.value = true
statusMessage.value = '' statusMessage.value = ''
statusError.value = '' statusError.value = ''
try { try {
@@ -131,14 +251,14 @@ async function saveSponsor() {
} catch (error) { } catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.' statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
} finally { } finally {
saving.value = false localSaving.value = false
} }
} }
async function deleteSponsor(sponsor: AdminSponsorItem) { async function deleteSponsor(sponsor: AdminSponsorItem) {
const resolvedSeasonId = await ensureSeasonId() const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
saving.value = true localSaving.value = true
statusMessage.value = '' statusMessage.value = ''
statusError.value = '' statusError.value = ''
try { try {
@@ -148,14 +268,14 @@ async function deleteSponsor(sponsor: AdminSponsorItem) {
} catch (error) { } catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.' statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
} finally { } finally {
saving.value = false localSaving.value = false
} }
} }
async function updateShowactStatus(applicationId: number, status: string) { async function updateShowactStatus(applicationId: number, status: string) {
const resolvedSeasonId = await ensureSeasonId() const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return if (!resolvedSeasonId) return
saving.value = true localSaving.value = true
statusMessage.value = '' statusMessage.value = ''
statusError.value = '' statusError.value = ''
try { try {
@@ -167,14 +287,14 @@ async function updateShowactStatus(applicationId: number, status: string) {
} catch (error) { } catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.' statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
} finally { } finally {
saving.value = false localSaving.value = false
} }
} }
async function deleteShowact(applicationId: number, artistName: string) { async function deleteShowact(applicationId: number, artistName: string) {
const resolvedSeasonId = await ensureSeasonId() const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
saving.value = true localSaving.value = true
statusMessage.value = '' statusMessage.value = ''
statusError.value = '' statusError.value = ''
try { try {
@@ -184,7 +304,7 @@ async function deleteShowact(applicationId: number, artistName: string) {
} catch (error) { } catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.' statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
} finally { } finally {
saving.value = false localSaving.value = false
} }
} }
@@ -197,97 +317,151 @@ function statusLabel(status: string) {
}[status] ?? status }[status] ?? status
} }
function formatDate(value: string) {
const parsed = new Date(`${value}T00:00:00`)
return Number.isNaN(parsed.getTime())
? value
: parsed.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function parseShowactResponses(raw: string) {
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [] as Array<[string, unknown]>
}
return Object.entries(parsed)
} catch {
return [] as Array<[string, unknown]>
}
}
function normalizeDetailValue(value: unknown) {
if (Array.isArray(value)) {
return value
.map((item) => String(item).trim())
.filter(Boolean)
.join(', ')
}
if (typeof value === 'boolean') {
return value ? 'Ja' : 'Nein'
}
if (value == null) {
return ''
}
return String(value).trim()
}
function humanizeShowactKey(key: string) {
return key
.replace(/_/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim()
.replace(/^./, (char) => char.toUpperCase())
}
function openShowactDetails(applicationId: number) {
selectedShowactId.value = applicationId
}
function closeShowactDetails() {
selectedShowactId.value = null
}
onMounted(loadLandingExtras) onMounted(loadLandingExtras)
</script> </script>
<template> <template>
<Card id="content-landing-extras" class="p-6"> <div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <p class="text-sm text-slate-500">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Landingpage Extras</p> <template v-if="props.section === 'showacts'">
<h2 class="mt-1 text-xl font-bold text-slate-900">Showacts und Sponsoren</h2> {{ store.adminShowactApplications.length }} Bewerbungen · {{ pendingShowacts }} offen
<p class="mt-2 text-sm leading-6 text-slate-500"> </template>
Module, Bewerbungen und Sponsoren direkt dort steuern, wo sie public erscheinen. <template v-else>
</p> {{ store.adminSponsors.length }} Sponsoren · {{ visibleSponsors }} sichtbar
</div> </template>
</p>
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" :disabled="loading" @click="loadLandingExtras"> <Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" :disabled="loading" @click="loadLandingExtras">
<Settings2 class="h-4 w-4" /> <Settings2 class="h-4 w-4" />
{{ loading ? 'Laedt ...' : 'Neu laden' }} {{ loading ? 'Laedt ...' : 'Neu laden' }}
</Button> </Button>
</div> </div>
<div class="mt-5 grid gap-3 md:grid-cols-3"> <!-- Showacts section -->
<div class="rounded-2xl border border-fuchsia-100 bg-fuchsia-50/55 p-4"> <template v-if="props.section === 'showacts'">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-fuchsia-600">Showacts</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminShowactApplications.length }}</p>
<p class="text-sm text-slate-500">{{ pendingShowacts }} offen</p>
</div>
<div class="rounded-2xl border border-sky-100 bg-sky-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-sky-600">Sponsoren</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminSponsors.length }}</p>
<p class="text-sm text-slate-500">{{ visibleSponsors }} sichtbar</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-600">Saison</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.overview.year || store.adminSeasonDetail.year || '' }}</p>
<p class="text-sm text-slate-500">Landingpage-Jahr</p>
</div>
</div>
<section class="mt-6 grid gap-3 lg:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-white p-4"> <div class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div>
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3> <h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Public-Formular auf der Landingpage anzeigen.</p> <p class="mt-1 text-sm leading-6 text-slate-500">
{{ showactStatusText }}
</p>
</div> </div>
<AdminSettingsToggle <AdminSettingsToggle
:model-value="settingsForm.showactApplicationsEnabled" :model-value="settingsForm.showactApplicationsEnabled"
label="Showact-Bewerbungen aktivieren" label="Showact-Bewerbungen aktivieren"
active-label="Offen" active-label="Bewerbung offen"
inactive-label="Zu" inactive-label="Bewerbung geschlossen"
@update:model-value="settingsForm.showactApplicationsEnabled = $event" @update:model-value="settingsForm.showactApplicationsEnabled = $event"
/> />
</div> </div>
</div> </div>
<div class="rounded-2xl border border-violet-100 bg-white p-4"> <div class="grid gap-4 md:grid-cols-2">
<div class="flex items-start justify-between gap-4"> <label class="block space-y-2">
<div> <span class="text-sm font-bold text-slate-900">Starttermin (optional)</span>
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3> <input
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p> v-model="settingsForm.showactApplicationStartsAt"
</div> type="date"
<AdminSettingsToggle class="h-12 w-full rounded-2xl border border-violet-100 bg-white px-4 text-sm text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
:model-value="settingsForm.sponsorsVisible"
label="Sponsoren anzeigen"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="settingsForm.sponsorsVisible = $event"
/> />
</div> </label>
<label class="block space-y-2">
<span class="text-sm font-bold text-slate-900">Deadline (optional)</span>
<input
v-model="settingsForm.showactApplicationEndsAt"
type="date"
class="h-12 w-full rounded-2xl border border-violet-100 bg-white px-4 text-sm text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</label>
</div> </div>
</section>
<label class="mt-4 block space-y-2"> <p v-if="showactScheduleError" class="text-sm font-semibold text-rose-700">
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Showact-Bewerbungen</span> {{ showactScheduleError }}
<textarea </p>
v-model="settingsForm.showactApplicationDisabledMessage"
rows="3" <label class="block space-y-2">
maxlength="240" <span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Bewerbungen</span>
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100" <textarea
v-model="settingsForm.showactApplicationDisabledMessage"
rows="3"
maxlength="240"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="flex flex-wrap items-center gap-3">
<Button class="gap-2" :disabled="props.saving || localSaving" @click="saveAll">
<Save class="h-4 w-4" />
{{ (props.saving || localSaving) ? 'Speichert ...' : 'Einstellungen speichern' }}
</Button>
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
</div>
<AdminShowactFormBuilder
:form="props.form"
:saving="props.saving"
:save-site-settings="props.saveSiteSettings"
/> />
</label>
<div class="mt-4 flex flex-wrap items-center gap-3">
<Button class="gap-2" :disabled="saving" @click="saveLandingExtrasSettings">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Landingpage Extras speichern' }}
</Button>
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
</div>
<section class="mt-7 grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)]">
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white"> <div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4"> <div class="border-b border-violet-100 p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -309,7 +483,7 @@ onMounted(loadLandingExtras)
<p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p> <p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" :disabled="saving" @click="updateShowactStatus(application.id, 'shortlisted')">Shortlist</Button> <Button size="sm" variant="ghost" :disabled="saving" @click="openShowactDetails(application.id)">Details</Button>
<Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button> <Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button>
<Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button> <Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button>
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)"> <Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)">
@@ -317,13 +491,28 @@ onMounted(loadLandingExtras)
</Button> </Button>
</div> </div>
</div> </div>
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2"> <!-- Dynamic field responses if available, otherwise legacy fixed fields -->
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a> <template v-if="application.fieldResponsesJson && application.fieldResponsesJson !== '{}'">
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a> <div class="grid gap-2 text-sm text-slate-600 md:grid-cols-2">
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span> <template v-for="(value, key) in (() => { try { return JSON.parse(application.fieldResponsesJson) } catch { return {} } })()" :key="key">
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span> <div v-if="value" class="flex flex-col gap-0.5">
</div> <span class="text-xs font-bold uppercase tracking-wide text-slate-400">{{ key }}</span>
<span>{{ value }}</span>
</div>
</template>
</div>
</template>
<template v-else>
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
</div>
</template>
<textarea <textarea
v-model="showactNotes[application.id]" v-model="showactNotes[application.id]"
rows="2" rows="2"
@@ -334,6 +523,47 @@ onMounted(loadLandingExtras)
</article> </article>
</div> </div>
</div> </div>
</template>
<!-- Sponsors section -->
<template v-else>
<div class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
</div>
<AdminSettingsToggle
:model-value="settingsForm.sponsorsVisible"
label="Sponsoren anzeigen"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="settingsForm.sponsorsVisible = $event"
/>
</div>
</div>
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="previewOpen = true">
<Eye class="h-4 w-4" />
Preview öffnen
</Button>
</div>
<AdminRichTextEditor
v-model="props.form.sponsorsContent"
label="Sponsoren-Seiteninhalt"
placeholder="Texte für die Sponsoren-Seite (wird als Modal auf der Landingpage angezeigt)..."
min-height-class="min-h-[220px]"
/>
<div class="flex flex-wrap items-center gap-3">
<Button class="gap-2" :disabled="props.saving || localSaving" @click="saveAll">
<Save class="h-4 w-4" />
{{ (props.saving || localSaving) ? 'Speichert ...' : 'Einstellungen speichern' }}
</Button>
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
</div>
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white"> <div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4"> <div class="border-b border-violet-100 p-4">
@@ -388,6 +618,54 @@ onMounted(loadLandingExtras)
</article> </article>
</div> </div>
</div> </div>
</section> </template>
</Card> </div>
<AdminContentFooterPreviewModal
:open="previewOpen"
:title="previewTitle"
url=""
:content-html="previewHtml"
@close="previewOpen = false"
/>
<Modal
:open="selectedShowact !== null"
size="xl"
:title="selectedShowact ? `${selectedShowact.artistName} · Showact-Details` : 'Showact-Details'"
:subtitle="selectedShowact ? `${statusLabel(selectedShowact.status)} · Eingereicht am ${new Date(selectedShowact.createdAt).toLocaleString('de-DE')}` : undefined"
@close="closeShowactDetails"
>
<div v-if="selectedShowact" class="space-y-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Artist</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.artistName }}</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ statusLabel(selectedShowact.status) }}</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">E-Mail</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.contactEmail || 'Nicht angegeben' }}</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Discord</p>
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.contactDiscord || 'Nicht angegeben' }}</p>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div
v-for="detail in showactSubmissionDetails"
:key="detail.key"
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{{ detail.label }}</p>
<p class="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-slate-800">{{ detail.value }}</p>
</div>
</div>
</div>
</Modal>
</template> </template>
@@ -0,0 +1,55 @@
<template>
<div class="space-y-4">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="previewOpen = true">
<Eye class="h-4 w-4" />
Preview öffnen
</Button>
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : `${props.label} speichern` }}
</Button>
</div>
<AdminRichTextEditor
v-model="form[props.contentKey]"
:label="props.label"
:placeholder="props.placeholder"
min-height-class="min-h-[420px]"
/>
</div>
<AdminContentFooterPreviewModal
:open="previewOpen"
:title="props.label"
url=""
:content-html="previewHtml"
@close="previewOpen = false"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Eye, Save } from '@lucide/vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
import Button from '../ui/Button.vue'
import AdminContentFooterPreviewModal from './AdminContentFooterPreviewModal.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
label: string
placeholder: string
contentKey: 'imprintContent' | 'contactContent' | 'sponsorsContent'
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
const previewOpen = ref(false)
const previewHtml = computed(() => privacyContentToHtml(props.form[props.contentKey]))
function onSave() {
return props.saveSiteSettings(props.label)
}
</script>
@@ -31,9 +31,13 @@
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p> <p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
</div> </div>
</div> </div>
<label class="mt-6 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz E-Mail</span>
<input v-model="form.privacyEmail" type="email" placeholder="datenschutz@..." class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<AdminRichTextEditor <AdminRichTextEditor
v-model="form.privacyPolicyContent" v-model="form.privacyPolicyContent"
class="mt-6" class="mt-4"
label="Datenschutz Inhalt" label="Datenschutz Inhalt"
placeholder="Datenschutztext..." placeholder="Datenschutztext..."
min-height-class="min-h-[560px]" min-height-class="min-h-[560px]"
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { KeyRound } from '@lucide/vue'
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import PasswordField from '../ui/PasswordField.vue'
defineProps<{
form: AdminOperationalSettingsForm
saving: boolean
canManage: boolean
demoPassword: string
demoPasswordHint: string
demoPasswordSet: boolean
demoManagedByDatabase: boolean
demoCredentialsComplete: boolean
}>()
const emit = defineEmits<{
'update:demoPassword': [value: string]
}>()
</script>
<template>
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<KeyRound class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Demo Login</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Landingpage temporär sperren</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Für Demo-Deployments oder nicht öffentliche Tests. Danach kann der Schutz hier wieder aus.
</p>
</div>
</div>
<AdminSettingsToggle
v-model="form.demoLoginEnabled"
label="Demo Login aktivieren"
:disabled="saving || !canManage"
/>
</div>
<p
v-if="form.demoLoginEnabled && !demoCredentialsComplete"
class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
>
Zum Aktivieren fehlen noch Login, Twitch-ID, Anzeigename oder ein gesetztes Passwort.
</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Login</span>
<input v-model="form.demoLoginIdentifier" :disabled="saving || !canManage" type="text" autocomplete="username" placeholder="E-Mail oder Username" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
<span class="mt-2 block text-xs leading-5 text-slate-500">E-Mail, Username oder dieselbe Kennung wie die Twitch-ID.</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
<PasswordField
:model-value="demoPassword"
:disabled="saving || !canManage"
autocomplete="new-password"
placeholder="Leer lassen = behalten"
root-class="mt-2"
input-class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
@update:model-value="emit('update:demoPassword', $event)"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Twitch-ID</span>
<input v-model="form.demoLoginTwitchUserId" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="form.demoLoginDisplayName" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
</label>
</div>
<div class="mt-4 flex flex-wrap gap-2 text-xs font-semibold">
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">{{ demoManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}</span>
<span class="rounded-full px-3 py-1" :class="demoPasswordSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ demoPasswordSet ? 'Passwort vorhanden' : 'Passwort fehlt' }}
</span>
</div>
</section>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { ShieldCheck, Wrench } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
defineProps<{
form: AdminOperationalSettingsForm
saving: boolean
canManage: boolean
}>()
</script>
<template>
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl" :class="form.maintenanceModeEnabled ? 'bg-amber-100 text-amber-700' : 'bg-violet-100 text-violet-700'">
<Wrench class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em]" :class="form.maintenanceModeEnabled ? 'text-amber-600' : 'text-violet-500'">Wartungsmodus</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Sternenpause anzeigen</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Besucher landen auf der Wartungsseite. Login und Admin bleiben erreichbar.
</p>
</div>
</div>
<AdminSettingsToggle
v-model="form.maintenanceModeEnabled"
label="Wartungsmodus aktivieren"
tone="amber"
:disabled="saving || !canManage"
/>
</div>
<div class="mt-5 space-y-4">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel der Wartungsseite</span>
<input v-model="form.maintenanceTitle" :disabled="saving || !canManage" type="text" maxlength="120" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Hinweistext</span>
<textarea v-model="form.maintenanceMessage" :disabled="saving || !canManage" rows="4" maxlength="600" class="mt-2 w-full resize-none rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"></textarea>
</label>
<RouterLink to="/maintenance?preview=true" class="inline-flex h-11 items-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-700 transition hover:bg-violet-50">
<ShieldCheck class="h-4 w-4" />
Wartungsseite ansehen
</RouterLink>
</div>
</section>
</template>
@@ -1,52 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted } from 'vue'
import { Sparkles, X } from '@lucide/vue' import { Sparkles, X } from '@lucide/vue'
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue' import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
import { useAdminReviewsManager } from './useAdminReviewsManager'
import { useBodyScrollLock } from '../../composables/useBodyScrollLock' import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
import { watchAdminToast } from '../../composables/useAdminToast' import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
nomination: AdminNominationReviewGroup | null
reviewSaving: number | null
reviewForm: {
displayName: string
channelSlug: string
platform: string
categoryId: number | null
reviewNote: string
} | null
candidatePlatformOptions: Array<{ key: string; label: string }>
categoryOptions: Array<{ id: number; label: string }>
selectedCandidateCollision: AdminCandidateItem | null
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
signalSummary?: {
submissions: number
uniqueSubmitters: number
platforms: string[]
} | null
trackingReviewNotes?: string
selectedNominationHasBlockingFlag?: boolean
streamUrl?: string
canApproveSelected: boolean
blacklistSaving: boolean
selectedPlatformValue: (platform: string) => string
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
'platform-change': [value: string]
approve: [nominationId: number]
reject: [nominationId: number]
reviewFlagged: [nominationId: number]
overrideFlagged: [nominationId: number]
'blacklist-link': [streamUrl: string]
}>() }>()
const { const modalTitle = computed(() => props.nomination?.displayName || 'Nominierung entscheiden')
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
seasonDetail,
reviewFilter,
categoryFilter,
selectedNominationId,
candidatePlatformOptions,
filteredNominations,
selectedNomination,
reviewStats,
reviewedNominations,
categoryOptions,
selectedCandidateCollision,
selectedRelatedPendingNominations,
selectedNominationSignalSummary,
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
} = useAdminReviewsManager()
watchAdminToast(adminMessage, adminError)
function closeModal() { function closeModal() {
emit('close') emit('close')
@@ -84,7 +83,7 @@ onBeforeUnmount(() => {
</span> </span>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p> <p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p>
</div> </div>
<h2 class="mt-3 text-2xl font-bold text-slate-950">Nominierung entscheiden</h2> <h2 class="mt-3 text-2xl font-bold text-slate-950">{{ modalTitle }}</h2>
<p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500"> <p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme. Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme.
</p> </p>
@@ -99,83 +98,30 @@ onBeforeUnmount(() => {
</button> </button>
</header> </header>
<div class="min-h-0 overflow-y-auto bg-white"> <div class="min-h-0 overflow-y-auto p-4 sm:p-6">
<AdminReviewsQueueHeader <AdminReviewDecisionPanel
v-model:review-filter="reviewFilter" :nomination="nomination"
v-model:category-filter="categoryFilter" :review-saving="reviewSaving"
:total-pending="seasonDetail.pendingNominations.length" :review-form="reviewForm"
:visible-count="filteredNominations.length" :candidate-platform-options="candidatePlatformOptions"
:review-stats="reviewStats"
:category-options="categoryOptions" :category-options="categoryOptions"
:selected-candidate-collision="selectedCandidateCollision"
:selected-related-pending-nominations="selectedRelatedPendingNominations"
:signal-summary="signalSummary"
:tracking-review-notes="trackingReviewNotes"
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
:stream-url="streamUrl"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
:tracker-status-meta="trackerStatusMeta"
@platform-change="emit('platform-change', $event)"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
@review-flagged="emit('reviewFlagged', $event)"
@override-flagged="emit('overrideFlagged', $event)"
@blacklist-link="emit('blacklist-link', $event)"
/> />
<div class="space-y-4 p-4 sm:p-6">
<div class="grid gap-5 xl:grid-cols-[minmax(300px,0.8fr)_minmax(0,1.2fr)]">
<div class="space-y-2">
<!-- Keyboard shortcut legend fixed above scrollable list -->
<div v-if="filteredNominations.length > 0" class="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-2xl border border-violet-100 bg-violet-50/60 px-3 py-2">
<span class="text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">Tastaturkürzel</span>
<span class="flex items-center gap-1.5">
<span class="inline-flex gap-0.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm"></kbd>
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm"></kbd>
</span>
<span class="text-[11px] text-slate-500">/ </span>
<span class="inline-flex gap-0.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">J</kbd>
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">K</kbd>
</span>
<span class="text-[11px] text-slate-500">Navigieren</span>
</span>
<span class="flex items-center gap-1.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-emerald-200 bg-emerald-50 px-1.5 text-[10px] font-bold text-emerald-700 shadow-sm">A</kbd>
<span class="text-[11px] text-slate-500">Annehmen</span>
</span>
<span class="flex items-center gap-1.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-rose-200 bg-rose-50 px-1.5 text-[10px] font-bold text-rose-700 shadow-sm">R</kbd>
<span class="text-[11px] text-slate-500">Ablehnen</span>
</span>
</div>
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
</div>
<AdminReviewDecisionPanel
:nomination="selectedNomination"
:review-saving="reviewSaving"
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
:candidate-platform-options="candidatePlatformOptions"
:selected-candidate-collision="selectedCandidateCollision"
:selected-related-pending-nominations="selectedRelatedPendingNominations"
:signal-summary="selectedNominationSignalSummary"
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
@blacklist-link="addStreamUrlToBlacklist"
/>
</div>
<details class="rounded-[24px] border border-violet-100 bg-violet-50/40">
<summary class="cursor-pointer px-5 py-4 text-sm font-semibold text-violet-900">
Letzte Entscheidungen anzeigen
</summary>
<div class="border-t border-violet-100 p-5">
<AdminReviewsHistorySection
:reviewed-nominations="reviewedNominations"
:reviewed-total="seasonDetail.reviewedNominations.length"
/>
</div>
</details>
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue' import { Clock3, KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes' import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
@@ -225,6 +225,39 @@ function toneClasses(tone: AdminSettingsTone) {
</div> </div>
</section> </section>
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Clock3 class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Session Timeout</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Automatischer Logout bei Inaktivitaet</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Team- und Admin-Sessions werden nach der eingestellten Zeit ohne Aktivitaet automatisch beendet. Minimum sind 3 Stunden.
</p>
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-[minmax(0,220px)_1fr] md:items-start">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Inaktiv nach Stunden</span>
<input
v-model.number="form.sessionIdleTimeoutHours"
:disabled="saving || !canManage"
type="number"
min="3"
step="1"
inputmode="numeric"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
</div>
</div>
</section>
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'"> <section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex gap-3"> <div class="flex gap-3">
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { Save } from '@lucide/vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
form: {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
clipAdminMenuVisible: boolean
clipSubmissionDisabledMessage: string
}
saving: boolean
dirty: boolean
canManage: boolean
}>()
const emit = defineEmits<{
save: []
'update:clipSubmissionsEnabled': [value: boolean]
'update:clipReviewEnabled': [value: boolean]
'update:clipAdminMenuVisible': [value: boolean]
'update:clipSubmissionDisabledMessage': [value: string]
}>()
</script>
<template>
<Card class="overflow-hidden">
<section class="border-b border-violet-100 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Optionale Workflows</p>
<h2 class="mt-2 text-lg font-bold text-slate-900">Clip-Einreichungen direkt steuern</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Clip-Einreichung und Clip-Review sind optionale Betriebsfunktionen. Wenn deaktiviert, bleibt der Award-Workflow ohne Clip-Pflicht nutzbar.
</p>
</div>
<Button class="gap-2" :disabled="saving || !dirty || !canManage" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Clip-Workflow speichern' }}
</Button>
</div>
</section>
<section v-if="!canManage" class="border-b border-amber-100 bg-amber-50 px-5 py-3 text-sm font-semibold text-amber-800">
Du kannst den Clip-Workflow ansehen, aber nicht bearbeiten.
</section>
<section class="space-y-5 p-5">
<section class="grid gap-3 md:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Public-Einreichung</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Besucher können während der passenden Phase Clips einreichen.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipSubmissionsEnabled"
label="Clip-Einreichung aktivieren"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('update:clipSubmissionsEnabled', $event)"
/>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Admin-Review</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Clip-Bestand und Alt-Einreichungen bleiben als optionale Inbox sichtbar.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipReviewEnabled"
label="Admin-Review anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipReviewEnabled', $event)"
/>
</div>
</div>
</section>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Menüpunkt Clips" im Admin</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Blendet den Clips-Menüpunkt im Admin-Panel ein oder aus, unabhängig von Einreichung und Review.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipAdminMenuVisible"
label="Clips-Menüpunkt anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipAdminMenuVisible', $event)"
/>
</div>
</div>
<label class="block space-y-2">
<span class="text-sm font-bold text-slate-900">Hinweis bei deaktivierter Einreichung</span>
<textarea
:value="form.clipSubmissionDisabledMessage"
rows="3"
maxlength="240"
:disabled="!canManage"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
@input="emit('update:clipSubmissionDisabledMessage', ($event.target as HTMLTextAreaElement).value)"
/>
<span class="text-xs font-semibold text-slate-500">
{{ form.clipSubmissionDisabledMessage.length }}/240 Zeichen
</span>
</label>
<section class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-800">
Nominierung, Voting und Gewinnerpflege laufen unabhängig davon weiter. Bestehende Clips werden nicht gelöscht.
</section>
</section>
</Card>
</template>
@@ -1,16 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { BookOpenText, CheckCircle2, History, Info, Rocket, ShieldCheck, Sparkles, Tag } from '@lucide/vue' import { BookOpenText, CheckCircle2, ChevronDown, History, Info, Rocket, ShieldCheck, Sparkles, Tag } from '@lucide/vue'
import { buildDateLabel, buildVersion, releaseNotes, semanticVersion, versionRules, type ReleaseChangeType } from '../../lib/releaseNotes' import { buildDateLabel, buildVersion, releaseNotes, releaseVersions, semanticVersion, versionRules, type ReleaseChangeType } from '../../lib/releaseNotes'
import Button from '../ui/Button.vue' import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue' import Card from '../ui/Card.vue'
import Modal from '../ui/Modal.vue' import Modal from '../ui/Modal.vue'
const changelogOpen = ref(false) const changelogOpen = ref(false)
const expandedVersions = ref<string[]>([releaseVersions[0]?.version ?? semanticVersion])
const featureCount = computed(() => releaseNotes.filter((note) => note.type === 'feature').length) const featureCount = computed(() => releaseNotes.filter((note) => note.type === 'feature').length)
const fixCount = computed(() => releaseNotes.filter((note) => note.type === 'fix').length) const fixCount = computed(() => releaseNotes.filter((note) => note.type === 'fix').length)
const versionCount = computed(() => releaseVersions.length)
function versionFeatureCount(version: typeof releaseVersions[number]) {
return version.notes.filter((note) => note.type === 'feature').length
}
function versionFixCount(version: typeof releaseVersions[number]) {
return version.notes.filter((note) => note.type === 'fix').length
}
function isVersionExpanded(version: string) {
return expandedVersions.value.includes(version)
}
function toggleVersion(version: string) {
if (isVersionExpanded(version)) {
expandedVersions.value = expandedVersions.value.filter((entry) => entry !== version)
return
}
expandedVersions.value = [version]
}
function changeTypeLabel(type: ReleaseChangeType) { function changeTypeLabel(type: ReleaseChangeType) {
if (type === 'major') return 'Major' if (type === 'major') return 'Major'
@@ -83,6 +106,11 @@ function changeTypeClasses(type: ReleaseChangeType) {
<strong class="mt-1 block text-lg text-amber-700">{{ fixCount }}</strong> <strong class="mt-1 block text-lg text-amber-700">{{ fixCount }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-600">Stabilitaet, UI-Feedback und Backend-Hygiene.</p> <p class="mt-1 text-xs leading-5 text-slate-600">Stabilitaet, UI-Feedback und Backend-Hygiene.</p>
</div> </div>
<div class="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-3 md:col-span-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Versionen im Verlauf</p>
<strong class="mt-1 block text-lg text-slate-800">{{ versionCount }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-600">Neueste Builds lassen sich im Changelog einzeln auf- und zuklappen.</p>
</div>
</div> </div>
</Card> </Card>
@@ -115,32 +143,80 @@ function changeTypeClasses(type: ReleaseChangeType) {
<section> <section>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<History class="h-4 w-4 text-violet-600" /> <History class="h-4 w-4 text-violet-600" />
<h3 class="text-sm font-bold text-slate-900">Was neu ist</h3> <h3 class="text-sm font-bold text-slate-900">Versionen & Aenderungen</h3>
</div> </div>
<div class="mt-3 space-y-3"> <div class="mt-3 space-y-3">
<article <section
v-for="note in releaseNotes" v-for="version in releaseVersions"
:key="note.title" :key="version.version"
class="rounded-2xl border p-4" class="overflow-hidden rounded-[24px] border border-violet-100 bg-white/95 shadow-[0_16px_36px_rgba(124,92,255,0.08)]"
:class="changeTypeClasses(note.type).panel"
> >
<div class="flex items-start gap-3"> <button
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="changeTypeClasses(note.type).icon"> type="button"
<Sparkles v-if="note.type === 'feature'" class="h-4 w-4" /> class="flex w-full items-start justify-between gap-4 bg-gradient-to-r from-white via-violet-50/50 to-white px-5 py-4 text-left transition hover:bg-violet-50/70"
<ShieldCheck v-else-if="note.type === 'major'" class="h-4 w-4" /> @click="toggleVersion(version.version)"
<CheckCircle2 v-else class="h-4 w-4" /> >
</div>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-bold text-slate-900">{{ note.title }}</h4> <span class="rounded-full bg-slate-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(note.type).badge"> {{ version.version }}
{{ changeTypeLabel(note.type) }} </span>
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(version.releaseType).badge">
{{ changeTypeLabel(version.releaseType) }}
</span>
<span class="text-xs font-semibold text-slate-500">
{{ version.buildVersion ?? version.version }} · {{ version.buildDateLabel }}
</span> </span>
</div> </div>
<p class="mt-1 text-sm leading-6 text-slate-600">{{ note.description }}</p> <h4 class="mt-3 text-base font-bold text-slate-900">{{ version.summary }}</h4>
<div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-violet-700">
{{ versionFeatureCount(version) }} Features
</span>
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-emerald-700">
{{ versionFixCount(version) }} Fixes
</span>
<span class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-slate-600">
{{ version.notes.length }} Eintraege
</span>
</div>
</div>
<span
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl border border-violet-100 bg-white text-violet-700 transition"
:class="{ 'rotate-180': isVersionExpanded(version.version) }"
>
<ChevronDown class="h-4 w-4" />
</span>
</button>
<div v-if="isVersionExpanded(version.version)" class="border-t border-violet-100 bg-white px-5 py-4">
<div class="space-y-3">
<article
v-for="note in version.notes"
:key="`${version.version}-${note.title}`"
class="rounded-2xl border p-4"
:class="changeTypeClasses(note.type).panel"
>
<div class="flex items-start gap-3">
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="changeTypeClasses(note.type).icon">
<Sparkles v-if="note.type === 'feature'" class="h-4 w-4" />
<ShieldCheck v-else-if="note.type === 'major'" class="h-4 w-4" />
<CheckCircle2 v-else class="h-4 w-4" />
</div>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-bold text-slate-900">{{ note.title }}</h4>
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(note.type).badge">
{{ changeTypeLabel(note.type) }}
</span>
</div>
<p class="mt-1 text-sm leading-6 text-slate-600">{{ note.description }}</p>
</div>
</div>
</article>
</div> </div>
</div> </div>
</article> </section>
</div> </div>
</section> </section>
@@ -1,20 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ban, CheckCircle2, Loader2, Trash2 } from '@lucide/vue' import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
import Button from '../ui/Button.vue' import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue' import NativeSelect from '../ui/NativeSelect.vue'
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards' import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
defineProps<{ const props = defineProps<{
nomination: AdminNominationReviewItem | null nomination: AdminNominationReviewGroup | null
reviewSaving: number | null reviewSaving: number | null
reviewForm: { reviewForm: {
displayName: string displayName: string
channelSlug: string channelSlug: string
platform: string platform: string
categoryId: number | null
reviewNote: string reviewNote: string
} | null } | null
candidatePlatformOptions: Array<{ key: string; label: string }> candidatePlatformOptions: Array<{ key: string; label: string }>
categoryOptions: Array<{ id: number; label: string }>
selectedCandidateCollision: AdminCandidateItem | null selectedCandidateCollision: AdminCandidateItem | null
selectedRelatedPendingNominations?: AdminNominationReviewItem[] selectedRelatedPendingNominations?: AdminNominationReviewItem[]
signalSummary?: { signalSummary?: {
@@ -22,152 +24,291 @@ defineProps<{
uniqueSubmitters: number uniqueSubmitters: number
platforms: string[] platforms: string[]
} | null } | null
trackingReviewNotes?: string
selectedNominationHasBlockingFlag?: boolean
streamUrl?: string streamUrl?: string
canApproveSelected: boolean canApproveSelected: boolean
blacklistSaving: boolean blacklistSaving: boolean
selectedPlatformValue: (platform: string) => string selectedPlatformValue: (platform: string) => string
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'platform-change': [value: string] 'platform-change': [value: string]
approve: [nominationId: number] approve: [nominationId: number]
reject: [nominationId: number] reject: [nominationId: number]
reviewFlagged: [nominationId: number]
overrideFlagged: [nominationId: number]
'blacklist-link': [streamUrl: string] 'blacklist-link': [streamUrl: string]
}>() }>()
function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
return nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')?.windowLabel?.trim() ?? ''
}
</script> </script>
<template> <template>
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]"> <div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-4 shadow-[0_14px_36px_rgba(168,145,214,0.08)] sm:p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div class="space-y-4">
<div class="min-w-0"> <section class="rounded-[24px] border border-violet-100 bg-[linear-gradient(135deg,#fcfaff_0%,#ffffff_100%)] p-4 sm:p-5">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p> <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText || 'Name im Review festlegen' }}</h3> <div class="min-w-0">
<p class="mt-2 text-sm text-slate-500"> <div class="flex flex-wrap items-center gap-2">
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }} <span class="rounded-full bg-violet-100 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-violet-800">
</p> {{ nomination.categoryGroupName }}
</div> </span>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800"> <span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500">
ID {{ nomination.id }} Gruppe #{{ nomination.id }}
</div> </span>
</div> </div>
<h3 class="mt-3 break-words text-xl font-bold text-slate-900 sm:text-2xl">{{ nomination.displayName || 'Name im Review festlegen' }}</h3>
<p class="mt-2 text-sm leading-6 text-slate-500">
{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende · {{ nomination.nominationTally }} Nominierungen · zuletzt {{ new Date(nomination.lastSubmittedAt).toLocaleString('de-DE') }}
</p>
</div>
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4"> <div
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p> class="rounded-2xl border px-4 py-3 text-sm"
<div class="mt-2 flex flex-wrap items-center gap-2"> :class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
<a >
:href="streamUrl" <p class="text-[10px] font-bold uppercase tracking-[0.18em]">Tracker</p>
target="_blank" <p class="mt-1 font-semibold">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</p>
rel="noopener" </div>
class="inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline" </div>
>
<span class="truncate">{{ streamUrl }}</span>
</a>
<Button
type="button"
variant="ghost"
size="sm"
class="gap-2 rounded-full border-rose-200 bg-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
:disabled="blacklistSaving"
@click="emit('blacklist-link', streamUrl)"
>
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
<Ban v-else class="h-3.5 w-3.5" />
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
</Button>
</div>
</div>
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3"> <div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3"> <div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Einreichungen</p> <p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tier-Vorschlag</p>
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong> <strong class="mt-1 block text-sm text-violet-900">{{ nomination.suggestedCategoryName || 'manuell wählen' }}</strong>
</div> </div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3"> <div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige User</p> <p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Avg Viewer <span v-if="avgViewerWindowLabel(nomination)">· {{ avgViewerWindowLabel(nomination) }}</span></p>
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong> <strong class="mt-1 block text-xl text-violet-900">{{ nomination.avgViewers ?? 'offen' }}</strong>
</div> </div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3"> <div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p> <p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
<strong class="mt-1 block truncate text-sm text-violet-900">{{ signalSummary.platforms.join(', ') || 'keine' }}</strong> <strong class="mt-1 block text-sm text-violet-900">{{ signalSummary?.platforms.join(', ') || nomination.resolvedPlatform || 'keine' }}</strong>
</div> </div>
</div> </div>
</section>
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4"> <div class="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p> <div class="space-y-4">
<div class="mt-4 grid gap-4 md:grid-cols-3"> <section class="rounded-[24px] border border-violet-100 bg-violet-50/35 p-4">
<label class="space-y-2"> <div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span> <Link2 class="h-4 w-4 text-sky-700" />
<input <p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Quelle & Signale</p>
v-model="reviewForm.displayName" </div>
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Anzeigename"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input
v-model="reviewForm.channelSlug"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="channel"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<NativeSelect
:model-value="selectedPlatformValue(reviewForm.platform)"
:options="[
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
{ label: 'Eigene Plattform', value: 'custom' },
]"
@update:model-value="emit('platform-change', String($event))"
/>
</label>
</div>
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
<input
v-model="reviewForm.platform"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Cake, Booth, neue Plattform"
/>
</label>
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
</p>
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
<ul class="mt-2 space-y-1">
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}
</li>
</ul>
</div>
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
Anzeigename, Handle und Plattform sind Pflicht, damit der Kandidat später im Voting eindeutig erscheint.
</p>
<label class="mt-3 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
<textarea
v-model="reviewForm.reviewNote"
rows="3"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Warum wurde übernommen oder verworfen?"
/>
</label>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3"> <div v-if="streamUrl" class="mt-3 rounded-2xl border border-sky-100 bg-white p-3">
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="emit('reject', nomination.id)"> <a
<Trash2 class="mr-2 h-4 w-4" /> :href="streamUrl"
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }} target="_blank"
</Button> rel="noopener"
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" @click="emit('approve', nomination.id)"> class="block truncate text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
<CheckCircle2 class="mr-2 h-4 w-4" /> >
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }} {{ streamUrl }}
</Button> </a>
<div class="mt-3 flex flex-wrap gap-2">
<Button
type="button"
variant="ghost"
size="sm"
class="gap-2 rounded-full border-rose-200 bg-rose-50 px-3 text-rose-700 hover:bg-rose-100 hover:text-rose-800"
:disabled="blacklistSaving"
@click="emit('blacklist-link', streamUrl)"
>
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
<Ban v-else class="h-3.5 w-3.5" />
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
</Button>
</div>
</div>
<div v-if="signalSummary" class="mt-3 grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Nominierungen</p>
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige Nominierende</p>
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</p>
<strong class="mt-1 block text-sm text-violet-900">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</strong>
</div>
</div>
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50/75 p-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">Nominierungen dieser Gruppe</p>
<div class="mt-3 space-y-2">
<div
v-for="related in selectedRelatedPendingNominations"
:key="related.id"
class="rounded-2xl border border-amber-100 bg-white px-3 py-2 text-sm text-slate-700"
>
<p class="font-semibold">#{{ related.id }} · {{ related.submittedByTwitchId }}</p>
<p class="mt-1 break-words text-slate-500">{{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}</p>
</div>
</div>
</div>
</section>
<section
class="rounded-[24px] border p-4"
:class="nomination.trackingReviewStatus === 'overridden' ? 'border-emerald-100 bg-emerald-50/70' : nomination.requiresManualReview ? 'border-amber-100 bg-amber-50/70' : 'border-violet-100 bg-white'"
>
<div class="flex items-center gap-2">
<ShieldAlert class="h-4 w-4" :class="nomination.requiresManualReview ? 'text-amber-700' : 'text-violet-600'" />
<p class="text-[11px] font-semibold uppercase tracking-[0.18em]" :class="nomination.requiresManualReview ? 'text-amber-800' : 'text-violet-500'">Tracking Review</p>
</div>
<p class="mt-2 text-sm font-semibold text-slate-900">{{ props.trackerStatusMeta(nomination.trackerStatus).hint }}</p>
<div v-if="nomination.trackingFlags.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="flag in nomination.trackingFlags"
:key="flag.key"
class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-semibold text-amber-800"
>
{{ flag.label }}<span v-if="flag.blocksApproval"> · blockiert</span>
</span>
</div>
<div v-if="nomination.trackingMetrics.length" class="mt-4 grid gap-3 md:grid-cols-2">
<div v-for="metric in nomination.trackingMetrics" :key="metric.key" class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<div class="flex items-center justify-between gap-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">{{ metric.label }}</p>
<span
class="rounded-full border px-2 py-0.5 text-[10px] font-semibold"
:class="metric.sourceSupport === 'auto'
? (metric.present ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700')
: 'border-slate-200 bg-slate-50 text-slate-700'"
>
{{ metric.sourceSupport === 'auto' ? (metric.present ? 'vorhanden' : 'fehlt') : (metric.sourceSupport === 'manual' ? 'manuell' : 'kontext') }}
</span>
</div>
<strong class="mt-1 block text-sm text-violet-900">{{ metric.value }}</strong>
<p class="mt-1 text-xs text-slate-500">{{ metric.required ? 'Pflichtmetrik' : 'Optionale Metrik' }} · {{ metric.windowLabel }}</p>
</div>
</div>
<div v-if="trackingReviewNotes" class="mt-4 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Manual Review Notes</p>
<pre class="mt-2 whitespace-pre-wrap text-sm leading-6 text-sky-900">{{ trackingReviewNotes }}</pre>
</div>
</section>
</div>
<div class="space-y-4">
<section class="rounded-[24px] border border-violet-100 bg-violet-50/45 p-4 sm:p-5">
<div class="flex items-center gap-2">
<UserRoundCheck class="h-4 w-4 text-violet-700" />
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Kandidaten-Übernahme</p>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input
v-model="reviewForm.displayName"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Anzeigename"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input
v-model="reviewForm.channelSlug"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="channel"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Tier</span>
<NativeSelect
:model-value="reviewForm.categoryId ?? ''"
:options="categoryOptions.map((option) => ({ label: option.label, value: option.id }))"
@update:model-value="reviewForm.categoryId = Number($event) || null"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<NativeSelect
:model-value="selectedPlatformValue(reviewForm.platform)"
:options="[
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
{ label: 'Eigene Plattform', value: 'custom' },
]"
@update:model-value="emit('platform-change', String($event))"
/>
</label>
</div>
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
<input
v-model="reviewForm.platform"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Cake, Booth, neue Plattform"
/>
</label>
<p
v-if="!nomination.suggestedCategoryId"
class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
Kein automatischer Tier-Vorschlag vorhanden. Bitte manuell anhand Viewer-Range und Kanal einordnen.
</p>
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in diesem Tier bereits vorhanden.
</p>
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
<span v-if="selectedNominationHasBlockingFlag && nomination.trackingReviewStatus !== 'overridden'">
Mindestens ein Tracking-Flag blockiert die Freigabe. Bitte zuerst einen Override setzen.
</span>
<span v-else>
Anzeigename, Handle, Plattform und Tier sind Pflicht.
</span>
</p>
</section>
<section class="rounded-[24px] border border-violet-100 bg-white p-4 sm:p-5">
<div class="flex items-center gap-2">
<WandSparkles class="h-4 w-4 text-violet-700" />
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Moderationsnotiz & Entscheidung</p>
</div>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
<textarea
v-model="reviewForm.reviewNote"
rows="5"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Warum wurde übernommen oder verworfen?"
/>
</label>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button :disabled="reviewSaving === nomination.id" variant="secondary" class="justify-center" @click="emit('reject', nomination.id)">
<Trash2 class="mr-2 h-4 w-4" />
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" class="justify-center" @click="emit('approve', nomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button>
</div>
</section>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
defineProps<{
tabs: Array<{ id: number | null; label: string; count: number; note?: string }>
modelValue: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
</script>
<template>
<div class="rounded-[24px] border border-violet-100 bg-[linear-gradient(180deg,#fcfaff_0%,#f8f4ff_100%)] p-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">Unterkategorien</p>
<p class="mt-1 text-sm text-slate-500">Nur die aktuell gewählte Tier-Stufe wird in der Queue gezeigt.</p>
</div>
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<button
v-for="tab in tabs"
:key="tab.id ?? 'all'"
type="button"
class="rounded-[20px] border px-4 py-3 text-left transition"
:class="modelValue === tab.id ? 'border-violet-300 bg-white text-violet-900 shadow-[0_12px_28px_rgba(168,145,214,0.14)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/75 text-slate-600 hover:border-violet-200 hover:bg-white'"
@click="emit('update:modelValue', tab.id)"
>
<span class="flex items-start justify-between gap-3">
<span class="min-w-0">
<span class="block text-sm font-semibold">{{ tab.label }}</span>
<span v-if="tab.note" class="mt-1 block text-[11px] font-medium text-slate-500">{{ tab.note }}</span>
</span>
<span
class="inline-flex min-h-8 min-w-8 items-center justify-center rounded-full border px-2 text-xs font-bold"
:class="modelValue === tab.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-violet-50 text-violet-700'"
>
{{ tab.count }}
</span>
</span>
</button>
</div>
</div>
</template>
@@ -0,0 +1,148 @@
<script setup lang="ts">
import AdminNominationReviewModal from './AdminNominationReviewModal.vue'
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
import AdminReviewSubcategoryTabs from './AdminReviewSubcategoryTabs.vue'
import { getTrackerStatusMeta, useAdminReviewsManager } from './useAdminReviewsManager'
import { watchAdminToast } from '../../composables/useAdminToast'
const props = withDefaults(defineProps<{
categoryIdsFilter?: number[] | null
activeMainCategory?: string | null
showCategoryFilterChips?: boolean
historyCollapsible?: boolean
showBlacklistButton?: boolean
}>(), {
categoryIdsFilter: null,
activeMainCategory: null,
showCategoryFilterChips: true,
historyCollapsible: false,
showBlacklistButton: false,
})
const emit = defineEmits<{
'open-blacklist': []
}>()
const {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
seasonDetail,
reviewFilter,
categoryFilter,
subcategoryFilter,
selectedNominationId,
isReviewModalOpen,
candidatePlatformOptions,
filteredNominations,
selectedNomination,
reviewedNominations,
categoryOptions,
availableSubcategoryOptions,
tierCategoryOptions,
selectedCandidateCollision,
selectedRelatedPendingNominations,
selectedNominationSignalSummary,
trackingReviewNotes,
selectedNominationHasBlockingFlag,
canApproveSelected,
approveNomination,
rejectNomination,
reopenRejectedNomination,
updateTrackingReview,
addStreamUrlToBlacklist,
openNominationById,
closeNominationModal,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
} = useAdminReviewsManager({
categoryIdsFilter: () => props.categoryIdsFilter,
})
watchAdminToast(adminMessage, adminError)
</script>
<template>
<div class="min-h-0 overflow-y-auto bg-white">
<AdminReviewsQueueHeader
v-model:review-filter="reviewFilter"
v-model:category-filter="categoryFilter"
:total-pending="seasonDetail.pendingNominations.length"
:visible-count="filteredNominations.length"
:category-options="categoryOptions"
:active-main-category="props.activeMainCategory"
:show-category-filter-chips="showCategoryFilterChips"
:show-blacklist-button="showBlacklistButton"
@open-blacklist="emit('open-blacklist')"
/>
<div class="space-y-4 p-4 sm:p-6">
<AdminReviewSubcategoryTabs
v-model="subcategoryFilter"
:tabs="availableSubcategoryOptions"
/>
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
:tracker-status-meta="getTrackerStatusMeta"
@select="selectedNominationId = $event"
@open="openNominationById"
/>
<details v-if="historyCollapsible" class="rounded-[24px] border border-violet-100 bg-violet-50/40">
<summary class="cursor-pointer px-5 py-4 text-sm font-semibold text-violet-900">
Letzte Entscheidungen anzeigen
</summary>
<div class="border-t border-violet-100 p-5">
<AdminReviewsHistorySection
:reviewed-nominations="reviewedNominations"
:reviewed-total="seasonDetail.reviewedNominations.length"
:review-saving="reviewSaving"
@undo-rejected="reopenRejectedNomination"
/>
</div>
</details>
<AdminReviewsHistorySection
v-else
:reviewed-nominations="reviewedNominations"
:reviewed-total="seasonDetail.reviewedNominations.length"
:review-saving="reviewSaving"
@undo-rejected="reopenRejectedNomination"
/>
</div>
<AdminNominationReviewModal
:open="isReviewModalOpen"
:nomination="selectedNomination"
:review-saving="reviewSaving"
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
:candidate-platform-options="candidatePlatformOptions"
:category-options="tierCategoryOptions"
:selected-candidate-collision="selectedCandidateCollision"
:selected-related-pending-nominations="selectedRelatedPendingNominations"
:signal-summary="selectedNominationSignalSummary"
:tracking-review-notes="trackingReviewNotes"
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
:tracker-status-meta="getTrackerStatusMeta"
@close="closeNominationModal"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
@review-flagged="updateTrackingReview($event, 'reviewed')"
@override-flagged="updateTrackingReview($event, 'overridden')"
@blacklist-link="addStreamUrlToBlacklist"
/>
</div>
</template>
@@ -1,9 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { RotateCcw } from '@lucide/vue'
import type { AdminNominationReviewItem } from '../../types/awards' import type { AdminNominationReviewItem } from '../../types/awards'
defineProps<{ defineProps<{
reviewedNominations: AdminNominationReviewItem[] reviewedNominations: AdminNominationReviewItem[]
reviewedTotal: number reviewedTotal: number
reviewSaving?: number | null
}>()
const emit = defineEmits<{
'undo-rejected': [nominationId: number]
}>() }>()
</script> </script>
@@ -28,9 +35,21 @@ defineProps<{
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span> <span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
</p> </p>
</div> </div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="nomination.status === 'approved' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'"> <div class="flex items-center gap-2">
{{ nomination.status === 'approved' ? 'übernommen' : 'verworfen' }} <span class="rounded-full px-3 py-1 text-xs font-semibold" :class="nomination.status === 'approved' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'">
</span> {{ nomination.status === 'approved' ? 'übernommen' : 'verworfen' }}
</span>
<button
v-if="nomination.status === 'rejected'"
type="button"
class="inline-flex h-9 items-center justify-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-3 text-xs font-semibold text-violet-700 transition hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="reviewSaving === nomination.id"
@click="emit('undo-rejected', nomination.id)"
>
<RotateCcw class="h-3.5 w-3.5" />
{{ reviewSaving === nomination.id ? 'Öffnet ...' : 'Undo' }}
</button>
</div>
</div> </div>
<p v-if="nomination.reviewNote" class="mt-3 text-sm leading-6 text-slate-600">{{ nomination.reviewNote }}</p> <p v-if="nomination.reviewNote" class="mt-3 text-sm leading-6 text-slate-600">{{ nomination.reviewNote }}</p>
<p class="mt-2 text-xs text-slate-500"> <p class="mt-2 text-xs text-slate-500">
@@ -1,40 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import { Search } from '@lucide/vue' import { Ban, Search } from '@lucide/vue'
defineProps<{ defineProps<{
reviewFilter: string reviewFilter: string
categoryFilter: number | null categoryFilter: string | null
totalPending: number totalPending: number
visibleCount: number visibleCount: number
reviewStats: Array<{ label: string; value: number }> categoryOptions: Array<{ id: string; label: string; count: number }>
categoryOptions: Array<{ id: number; label: string; count: number }> activeMainCategory?: string | null
showCategoryFilterChips?: boolean
showBlacklistButton?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:reviewFilter': [value: string] 'update:reviewFilter': [value: string]
'update:categoryFilter': [value: number | null] 'update:categoryFilter': [value: string | null]
'open-blacklist': []
}>() }>()
</script> </script>
<template> <template>
<div class="border-b border-violet-100 bg-white/75 p-6"> <div class="border-b border-violet-100 bg-white/75 p-6">
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between"> <div class="space-y-5">
<div> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p> <div>
<h2 class="mt-1 text-xl font-bold text-slate-900">Offene Nominierungen</h2> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500"> <h2 class="mt-1 text-2xl font-bold text-slate-900">{{ activeMainCategory || 'Offene Nominierungen' }}</h2>
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird. <p class="mt-1 text-sm text-slate-500">Offene Nominierungen</p>
</p>
</div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div> </div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]"> <button
v-if="showBlacklistButton"
type="button"
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
@click="emit('open-blacklist')"
>
<Ban class="h-4 w-4" />
Blacklist verwalten
</button>
</div>
<label class="relative block"> <label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" /> <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input <input
@@ -45,11 +50,9 @@ const emit = defineEmits<{
@input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)" @input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)"
/> />
</label> </label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ visibleCount }} / {{ totalPending }} sichtbar
</div>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2">
<div v-if="showCategoryFilterChips !== false" class="mt-4 flex flex-wrap gap-2">
<button <button
type="button" type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition" class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
@@ -1,42 +1,129 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AdminNominationReviewItem } from '../../types/awards' import type { AdminNominationReviewGroup } from '../../types/awards'
defineProps<{ const props = defineProps<{
nominations: AdminNominationReviewItem[] nominations: AdminNominationReviewGroup[]
totalPending: number totalPending: number
selectedNominationId: number | null selectedNominationId: number | null
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
select: [nominationId: number] select: [nominationId: number]
open: [nominationId: number]
}>() }>()
function avgViewerLabel(nomination: AdminNominationReviewGroup) {
if (nomination.avgViewers === null) return null
const avgViewerMetric = nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')
const windowLabel = avgViewerMetric?.windowLabel?.trim()
return windowLabel
? `${nomination.avgViewers} Avg Viewer · ${windowLabel}`
: `${nomination.avgViewers} Avg Viewer`
}
</script> </script>
<template> <template>
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2"> <div class="space-y-3 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
<div v-if="nominations.length > 0" class="rounded-[22px] border border-violet-100 bg-violet-50/50 px-4 py-3">
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold text-slate-600">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-500">Queue-Navigation</span>
<div class="flex flex-wrap gap-2 text-xs font-semibold text-slate-600">
<span class="rounded-full border border-violet-100 bg-white px-3 py-1.5">
<kbd class="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700"></kbd>
<kbd class="ml-1 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700"></kbd>
<span class="ml-2">oder</span>
<kbd class="ml-2 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">J</kbd>
<kbd class="ml-1 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">K</kbd>
<span class="ml-2">Navigieren</span>
</span>
<span class="rounded-full border border-sky-100 bg-sky-50 px-3 py-1.5 text-sky-700">
<kbd class="rounded border border-sky-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">Enter</kbd>
<span class="ml-2">Modal öffnen</span>
</span>
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1.5 text-emerald-700">
<kbd class="rounded border border-emerald-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">A</kbd>
<span class="ml-2">Annehmen</span>
</span>
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-rose-700">
<kbd class="rounded border border-rose-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">R</kbd>
<span class="ml-2">Verwerfen</span>
</span>
</div>
</div>
</div>
<button <button
v-for="nomination in nominations" v-for="nomination in nominations"
:key="nomination.id" :key="nomination.id"
type="button" type="button"
class="w-full rounded-2xl border p-3 text-left transition" class="w-full rounded-[24px] border p-4 text-left transition"
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'" :class="selectedNominationId === nomination.id ? 'border-violet-300 bg-white shadow-[0_18px_34px_rgba(168,145,214,0.14)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
@click="emit('select', nomination.id)" @click="emit('select', nomination.id)"
> >
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700"> <span
{{ nomination.categoryName }} class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]"
:class="selectedNominationId === nomination.id ? 'bg-violet-100 text-violet-800' : 'bg-violet-50 text-violet-700'"
>
{{ nomination.categoryGroupName }}
</span> </span>
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600"> <span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
ID {{ nomination.id }} {{ nomination.uniqueSubmitterCount }} Nominierende
</span>
<span
class="rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]"
:class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
>
{{ props.trackerStatusMeta(nomination.trackerStatus).label }}
</span> </span>
</div> </div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3> <h3 class="mt-2 line-clamp-2 text-base font-semibold text-slate-900">{{ nomination.displayName || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
<p class="mt-1 truncate text-sm text-slate-500"> <div class="mt-2 flex flex-wrap gap-2 text-xs font-semibold text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }} <span class="rounded-full border border-violet-100 bg-violet-50/50 px-2.5 py-1">{{ nomination.suggestedCategoryName || 'Tier offen' }}</span>
<span class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">{{ nomination.nominationTally }} Nominierungen</span>
<span class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende</span>
<span v-if="avgViewerLabel(nomination)" class="rounded-full border border-sky-100 bg-sky-50 px-2.5 py-1 text-sky-700">{{ avgViewerLabel(nomination) }}</span>
<span
v-if="nomination.trackingFlags.some((flag) => flag.blocksApproval)"
class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-rose-700"
>
Blockierende Flags
</span>
<span
v-else-if="nomination.requiresManualReview"
class="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
>
Manuelle Pruefung
</span>
<span
v-if="!nomination.suggestedCategoryName"
class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-600"
>
Tier manuell waehlen
</span>
</div>
<p class="mt-2 text-xs font-medium text-slate-400">
zuletzt {{ new Date(nomination.lastSubmittedAt).toLocaleString('de-DE') }}
</p> </p>
</div> </div>
<div class="flex shrink-0 flex-col items-end gap-2">
<div
class="rounded-full border px-3 py-1.5 text-xs font-bold"
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-500'"
>
#{{ nomination.id }}
</div>
<button
type="button"
class="rounded-full border border-violet-200 bg-white px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
@click.stop="emit('open', nomination.id)"
>
Prüfen
</button>
</div>
</div> </div>
</button> </button>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { Clock3 } from '@lucide/vue'
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
defineProps<{
form: AdminOperationalSettingsForm
saving: boolean
canManage: boolean
}>()
</script>
<template>
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Clock3 class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Session Timeout</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Automatischer Logout bei Inaktivitaet</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Team- und Admin-Sessions werden nach der eingestellten Zeit ohne Aktivitaet automatisch beendet. Minimum sind 3 Stunden.
</p>
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-[minmax(0,220px)_1fr] md:items-start">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Inaktiv nach Stunden</span>
<input
v-model.number="form.sessionIdleTimeoutHours"
:disabled="saving || !canManage"
type="number"
min="3"
step="1"
inputmode="numeric"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
</div>
</div>
</section>
</template>

Some files were not shown because too many files have changed in this diff Show More