Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Common;
|
||||
|
||||
public static class ShowactApplicationSchedule
|
||||
{
|
||||
public static string? Validate(DateOnly? startsAt, DateOnly? endsAt)
|
||||
{
|
||||
if (startsAt.HasValue && endsAt.HasValue && startsAt.Value > endsAt.Value)
|
||||
{
|
||||
return "Der Showact-Zeitraum ist ungueltig. Der Start darf nicht nach der Deadline liegen.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsOpenNow(SiteSettings settings, DateOnly today)
|
||||
{
|
||||
if (!settings.ShowactApplicationsEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settings.ShowactApplicationStartsAt.HasValue && today < settings.ShowactApplicationStartsAt.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settings.ShowactApplicationEndsAt.HasValue && today > settings.ShowactApplicationEndsAt.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,27 @@ public sealed record AdminAuditEntriesResponse(
|
||||
|
||||
public sealed record AdminNominationReviewItemDto(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string CategoryGroupName,
|
||||
string CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
bool RequiresManualReview,
|
||||
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
@@ -68,6 +84,32 @@ public sealed record AdminNominationReviewItemDto(
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
public sealed record AdminNominationReviewGroupDto(
|
||||
int Id,
|
||||
int[] NominationIds,
|
||||
string CategoryGroupName,
|
||||
string DisplayName,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
bool RequiresManualReview,
|
||||
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
int NominationTally,
|
||||
int UniqueSubmitterCount,
|
||||
DateTimeOffset FirstSubmittedAt,
|
||||
DateTimeOffset LastSubmittedAt);
|
||||
|
||||
public sealed record AdminClipSubmissionItemDto(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
@@ -87,10 +129,17 @@ public sealed record ApproveNominationRequest(
|
||||
string? DisplayName,
|
||||
string? ChannelSlug,
|
||||
string? Platform,
|
||||
int? CategoryId,
|
||||
string? ReviewNote);
|
||||
|
||||
public sealed record RejectNominationRequest(string? ReviewNote);
|
||||
|
||||
public sealed record ReopenRejectedNominationRequest(string? ReviewNote);
|
||||
|
||||
public sealed record UpdateNominationTrackingReviewRequest(
|
||||
string Status,
|
||||
string? ReviewNote);
|
||||
|
||||
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
|
||||
|
||||
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
|
||||
@@ -136,3 +185,24 @@ public sealed record AdminWorkflowRuleDto(
|
||||
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
|
||||
|
||||
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
|
||||
|
||||
public sealed record AdminTrackingFlagHitDto(
|
||||
string Key,
|
||||
string Label,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record AdminTrackingMetricStateDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Required,
|
||||
string SourceSupport,
|
||||
bool Present,
|
||||
string Value,
|
||||
string Description,
|
||||
string WindowKey,
|
||||
string WindowLabel,
|
||||
bool AutoWindowSupported);
|
||||
|
||||
@@ -16,14 +16,25 @@ public sealed record AdminCategoryItemDto(
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax,
|
||||
int CandidateCount);
|
||||
|
||||
public sealed record AdminSubcategoryTemplateDto(
|
||||
string Name,
|
||||
string Slug,
|
||||
int SortOrder,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public sealed record AdminCandidateItemDto(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string Platform,
|
||||
int NominationTally,
|
||||
string AcceptanceStatus,
|
||||
string? AcceptanceNote,
|
||||
string? ClipCompilationUrl,
|
||||
@@ -36,6 +47,7 @@ public sealed record AdminAwardResultItemDto(
|
||||
int CategoryId,
|
||||
string CategoryName,
|
||||
int CandidateId,
|
||||
int? StreamerIdentityId,
|
||||
string CandidateDisplayName,
|
||||
string CandidateChannelSlug,
|
||||
string CandidatePlatform);
|
||||
@@ -56,10 +68,14 @@ public sealed record AdminSeasonDetailResponse(
|
||||
DateOnly ReviewEndsAt,
|
||||
DateOnly ShowDate,
|
||||
TimeOnly ShowStartsAt,
|
||||
IEnumerable<AdminSubcategoryTemplateDto> SubcategoryTemplates,
|
||||
IEnumerable<AdminCategoryItemDto> Categories,
|
||||
IEnumerable<AdminCandidateItemDto> Candidates,
|
||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
||||
IEnumerable<AdminNominationReviewGroupDto> PendingNominationGroups,
|
||||
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
||||
string TrackingReviewNotes,
|
||||
bool ShowTrackingReviewNotes,
|
||||
IEnumerable<AdminAwardResultItemDto> Results,
|
||||
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
||||
|
||||
@@ -102,6 +118,17 @@ public sealed record UpsertCategoryRequest(
|
||||
string Slug,
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public sealed record UpdateSeasonSubcategoryTemplatesRequest(
|
||||
AdminSubcategoryTemplateDto[] Templates);
|
||||
|
||||
public sealed record UpsertCategoryGroupRequest(
|
||||
string GroupName,
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser);
|
||||
|
||||
public sealed record UpsertCandidateRequest(
|
||||
|
||||
@@ -4,6 +4,8 @@ public sealed record AdminSiteSettingsResponse(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
string? PrivacyPolicyUpdatedBy,
|
||||
@@ -17,12 +19,15 @@ public sealed record AdminSiteSettingsResponse(
|
||||
string ShowactsUrl,
|
||||
string ShowactsContent,
|
||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||
IEnumerable<FaqItemDto> Faq);
|
||||
IEnumerable<FaqItemDto> Faq,
|
||||
string ShowactFormSchemaJson);
|
||||
|
||||
public sealed record UpdateSiteSettingsRequest(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
string ImprintUrl,
|
||||
@@ -34,7 +39,8 @@ public sealed record UpdateSiteSettingsRequest(
|
||||
string ShowactsUrl,
|
||||
string ShowactsContent,
|
||||
PublicSocialLinkDto[] SocialLinks,
|
||||
FaqItemDto[] Faq);
|
||||
FaqItemDto[] Faq,
|
||||
string? ShowactFormSchemaJson = null);
|
||||
|
||||
public sealed record AdminOperationalSettingsResponse(
|
||||
bool DemoLoginManagedByDatabase,
|
||||
@@ -49,6 +55,7 @@ public sealed record AdminOperationalSettingsResponse(
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
@@ -59,6 +66,9 @@ public sealed record AdminOptionalFeatureSettingsResponse(
|
||||
bool ClipAdminMenuVisible,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
bool ShowactApplicationsOpenNow,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
|
||||
@@ -68,6 +78,8 @@ public sealed record UpdateOptionalFeatureSettingsRequest(
|
||||
bool ClipAdminMenuVisible,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
|
||||
@@ -81,6 +93,71 @@ public sealed record UpdateOperationalSettingsRequest(
|
||||
string? TwitchClientSecret,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
|
||||
public sealed record AdminTrackingSourceDto(
|
||||
string ProviderKey,
|
||||
string ProviderLabel,
|
||||
string BaseUrl,
|
||||
string NotesSummary,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
public sealed record AdminTrackingMetricRuleDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string SourceSupport,
|
||||
string Description,
|
||||
bool RequiredForAutoClassification,
|
||||
bool ShowInReview,
|
||||
bool ShowInAdminSummary,
|
||||
bool ManualOverrideAllowed,
|
||||
string WindowKey,
|
||||
string[] AutoSupportedWindowKeys,
|
||||
string? ProviderFieldKey,
|
||||
int? TopCount,
|
||||
int? MinPrimaryCategorySharePercent,
|
||||
int? MinPrimaryCategoryHours,
|
||||
int? MaxDistinctCategoriesBeforeFlag,
|
||||
string[] IgnoredCategories,
|
||||
bool MatchAwardCategoryAgainstTopCategories,
|
||||
bool FlagIfAwardCategoryNotInTopX,
|
||||
bool FlagIfCategorySpreadTooWide,
|
||||
bool FlagIfNoCategoryContextAvailable,
|
||||
int? MinValue,
|
||||
int? MaxValue);
|
||||
|
||||
public sealed record AdminTrackingFlagRuleDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool AutoTriggerEnabled,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record AdminTrackingRulesResponse(
|
||||
AdminTrackingSourceDto Source,
|
||||
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||
AdminTrackingFlagRuleDto[] Flags,
|
||||
string ManualReviewNotes);
|
||||
|
||||
public sealed record UpdateTrackingRulesRequest(
|
||||
AdminTrackingSourceDto Source,
|
||||
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||
AdminTrackingFlagRuleDto[] Flags,
|
||||
string ManualReviewNotes);
|
||||
|
||||
public sealed record UpdateTrackingSourceRequest(
|
||||
AdminTrackingSourceDto Source);
|
||||
|
||||
public sealed record UpdateTrackingReviewNotesRequest(
|
||||
string ManualReviewNotes,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed record AuthSessionDto(
|
||||
string DisplayName,
|
||||
string Role,
|
||||
IEnumerable<string> PermissionKeys,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MustChangePassword = false,
|
||||
string? TeamLogin = null,
|
||||
string? BoundTwitchUserId = null,
|
||||
|
||||
@@ -36,16 +36,18 @@ public sealed record ShowactApplicationDto(
|
||||
string Status,
|
||||
string? ReviewNote,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
DateTimeOffset? ReviewedAt,
|
||||
string FieldResponsesJson);
|
||||
|
||||
public sealed record CreateShowactApplicationRequest(
|
||||
string ArtistName,
|
||||
string ContactEmail,
|
||||
string ContactDiscord,
|
||||
string PlatformUrl,
|
||||
string PerformanceType,
|
||||
string Description,
|
||||
string TechnicalNotes,
|
||||
string ReferenceUrl);
|
||||
string? ArtistName = null,
|
||||
string? ContactEmail = null,
|
||||
string? ContactDiscord = null,
|
||||
string? PlatformUrl = null,
|
||||
string? PerformanceType = null,
|
||||
string? Description = null,
|
||||
string? TechnicalNotes = null,
|
||||
string? ReferenceUrl = null,
|
||||
string? FieldResponsesJson = null);
|
||||
|
||||
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
|
||||
|
||||
@@ -50,6 +50,8 @@ public sealed record PublicSiteContentDto(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||
@@ -66,8 +68,11 @@ public sealed record PublicFeatureFlagsDto(
|
||||
bool ClipReviewEnabled,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
bool SponsorsVisible,
|
||||
string ShowactFormSchemaJson);
|
||||
|
||||
public sealed record OverviewResponse(
|
||||
int SeasonId,
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed record PublicCategoryDetailDto(
|
||||
string GroupName,
|
||||
string Description,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax,
|
||||
IEnumerable<CandidateSummaryDto> Candidates);
|
||||
|
||||
public sealed record SeasonCategoriesResponse(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Backend.Contracts;
|
||||
|
||||
public sealed record UserNominationStateDto(
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string CategoryGroupName,
|
||||
string[] Nominees);
|
||||
|
||||
public sealed record UserVoteStateDto(
|
||||
|
||||
@@ -6,7 +6,8 @@ public sealed record NominationEntryRequest(
|
||||
|
||||
public sealed record CreateNominationRequest(
|
||||
int Year,
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string TwitchUserId,
|
||||
string[]? Nominees,
|
||||
NominationEntryRequest[]? Nominations);
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
public DbSet<Season> Seasons => Set<Season>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<Candidate> Candidates => Set<Candidate>();
|
||||
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
|
||||
public DbSet<AwardResult> Results => Set<AwardResult>();
|
||||
public DbSet<Nomination> Nominations => Set<Nomination>();
|
||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||
@@ -30,6 +31,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.Name).HasMaxLength(160);
|
||||
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
|
||||
entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SiteSettings>(entity =>
|
||||
@@ -53,14 +56,19 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
||||
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
||||
entity.Property(item => item.TwitchScope).HasMaxLength(300);
|
||||
entity.Property(item => item.SessionIdleTimeoutHours).HasDefaultValue(3);
|
||||
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
||||
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
||||
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.TrackingRulesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.ViewerStatsProviderBaseUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
|
||||
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
|
||||
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
|
||||
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
|
||||
entity.Property(item => item.ShowactApplicationStartsAt);
|
||||
entity.Property(item => item.ShowactApplicationEndsAt);
|
||||
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
|
||||
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
|
||||
});
|
||||
@@ -93,13 +101,17 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.GroupName).HasMaxLength(80);
|
||||
entity.Property(item => item.Name).HasMaxLength(120);
|
||||
entity.Property(item => item.Description).HasMaxLength(400);
|
||||
entity.Property(item => item.ViewerRangeMin);
|
||||
entity.Property(item => item.ViewerRangeMax);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Candidate>(entity =>
|
||||
{
|
||||
entity.HasIndex(item => item.StreamerIdentityId);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||
entity.Property(item => item.NominationTally).HasDefaultValue(0);
|
||||
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
|
||||
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
|
||||
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
|
||||
@@ -108,15 +120,47 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<StreamerIdentity>(entity =>
|
||||
{
|
||||
entity.HasIndex(item => item.NormalizedKey).IsUnique();
|
||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||
entity.Property(item => item.Login).HasMaxLength(120);
|
||||
entity.Property(item => item.NormalizedKey).HasMaxLength(180);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.ProfileUrl).HasMaxLength(500);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Nomination>(entity =>
|
||||
{
|
||||
entity.Property(item => item.CategoryGroupName).HasMaxLength(80).HasDefaultValue(string.Empty);
|
||||
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.CandidateText).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamUrl).HasMaxLength(300);
|
||||
entity.Property(item => item.ResolvedChannel).HasMaxLength(120);
|
||||
entity.Property(item => item.ResolvedPlatform).HasMaxLength(40);
|
||||
entity.Property(item => item.HoursStreamed);
|
||||
entity.Property(item => item.HoursWatched);
|
||||
entity.Property(item => item.PeakViewers);
|
||||
entity.Property(item => item.FollowersGained);
|
||||
entity.Property(item => item.TrackerStatus).HasMaxLength(40).HasDefaultValue("pending");
|
||||
entity.Property(item => item.TrackingReviewStatus).HasMaxLength(30).HasDefaultValue("clear");
|
||||
entity.Property(item => item.TrackingFlagsJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.TrackingReviewNote).HasMaxLength(1000);
|
||||
entity.Property(item => item.TrackingReviewedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.Status).HasMaxLength(20);
|
||||
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||
entity.HasIndex(item => new { item.SeasonId, item.CategoryGroupName, item.Status });
|
||||
entity.HasIndex(item => new { item.SeasonId, item.StreamerIdentityId, item.CategoryGroupName });
|
||||
entity.HasOne(item => item.Category)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.CategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(item => item.SuggestedCategory)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.SuggestedCategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<VoteBallot>(entity =>
|
||||
|
||||
@@ -19,6 +19,15 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingRulesJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ViewerStatsProviderBaseUrl" character varying(400) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewNotes" text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
@@ -34,12 +43,30 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationStartsAt" date NULL;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationEndsAt" date NULL;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "HoursStreamed" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "HoursWatched" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "PeakViewers" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "FollowersGained" integer NULL;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
|
||||
|
||||
@@ -55,6 +82,9 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "SessionIdleTimeoutHours" integer NOT NULL DEFAULT 3;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '';
|
||||
|
||||
@@ -70,6 +100,9 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "Seasons"
|
||||
ADD COLUMN IF NOT EXISTS "SubcategoryTemplatesJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"SeasonId" integer NULL,
|
||||
@@ -254,6 +287,95 @@ public static class OperationalTablesBootstrapper
|
||||
ALTER TABLE "Candidates"
|
||||
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked';
|
||||
|
||||
ALTER TABLE "Categories"
|
||||
ADD COLUMN IF NOT EXISTS "ViewerRangeMin" integer NULL;
|
||||
|
||||
ALTER TABLE "Categories"
|
||||
ADD COLUMN IF NOT EXISTS "ViewerRangeMax" integer NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "StreamerIdentities" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"Platform" character varying(40) NOT NULL,
|
||||
"Login" character varying(120) NOT NULL,
|
||||
"NormalizedKey" character varying(180) NOT NULL,
|
||||
"DisplayName" character varying(120) NOT NULL,
|
||||
"ProfileUrl" character varying(500) NULL,
|
||||
"LastResolvedAt" timestamp with time zone NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_StreamerIdentities_NormalizedKey"
|
||||
ON "StreamerIdentities" ("NormalizedKey");
|
||||
|
||||
ALTER TABLE "Candidates"
|
||||
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
|
||||
|
||||
ALTER TABLE "Candidates"
|
||||
ADD COLUMN IF NOT EXISTS "NominationTally" integer NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_Candidates_StreamerIdentityId"
|
||||
ON "Candidates" ("StreamerIdentityId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_Candidates_StreamerIdentities_StreamerIdentityId'
|
||||
) THEN
|
||||
ALTER TABLE "Candidates"
|
||||
ADD CONSTRAINT "FK_Candidates_StreamerIdentities_StreamerIdentityId"
|
||||
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ALTER COLUMN "CategoryId" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "CategoryGroupName" character varying(80) NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE "Nominations" n
|
||||
SET "CategoryGroupName" = c."GroupName"
|
||||
FROM "Categories" c
|
||||
WHERE n."CategoryId" = c."Id"
|
||||
AND COALESCE(n."CategoryGroupName", '') = '';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "SuggestedCategoryId" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "ResolvedChannel" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "ResolvedPlatform" character varying(40) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "AvgViewers" integer NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackerStatus" character varying(40) NOT NULL DEFAULT 'pending';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackerCheckedAt" timestamp with time zone NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewStatus" character varying(30) NOT NULL DEFAULT 'clear';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingFlagsJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewNote" character varying(1000) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewedByTwitchId" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "TrackingReviewedAt" timestamp with time zone NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
|
||||
|
||||
@@ -269,6 +391,36 @@ public static class OperationalTablesBootstrapper
|
||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
|
||||
ON "Nominations" ("SeasonId", "Status");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_CategoryGroupName_Status"
|
||||
ON "Nominations" ("SeasonId", "CategoryGroupName", "Status");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName"
|
||||
ON "Nominations" ("SeasonId", "StreamerIdentityId", "CategoryGroupName");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_Nominations_StreamerIdentities_StreamerIdentityId'
|
||||
) THEN
|
||||
ALTER TABLE "Nominations"
|
||||
ADD CONSTRAINT "FK_Nominations_StreamerIdentities_StreamerIdentityId"
|
||||
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_Nominations_Categories_SuggestedCategoryId'
|
||||
) THEN
|
||||
ALTER TABLE "Nominations"
|
||||
ADD CONSTRAINT "FK_Nominations_Categories_SuggestedCategoryId"
|
||||
FOREIGN KEY ("SuggestedCategoryId") REFERENCES "Categories" ("Id")
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "TeamMembers" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"Login" character varying(80) NOT NULL,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
@@ -7,54 +8,137 @@ public static partial class SeedDataBootstrapper
|
||||
{
|
||||
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
|
||||
{
|
||||
var seasonCategories = await db.Categories
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToArrayAsync();
|
||||
.ToListAsync();
|
||||
var templates = SeedCatalog.DefaultSubcategoryTemplates
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
var usedCategories = new HashSet<Category>();
|
||||
|
||||
foreach (var category in seasonCategories)
|
||||
{
|
||||
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||
|
||||
var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug);
|
||||
category.GroupName = target.GroupName;
|
||||
category.Name = target.Name;
|
||||
category.Slug = target.Slug;
|
||||
category.Description = target.Description;
|
||||
category.SortOrder = target.SortOrder;
|
||||
var nextSortOrder = 1;
|
||||
foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder))
|
||||
{
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var category = FindReusableCategory(categories, award, template, usedCategories)
|
||||
?? new Category { SeasonId = season.Id };
|
||||
usedCategories.Add(category);
|
||||
|
||||
category.GroupName = award.Name;
|
||||
category.Name = template.Name;
|
||||
category.Slug = BuildCategorySlug(award.Slug, template.Slug);
|
||||
category.Description = award.Description;
|
||||
category.SortOrder = nextSortOrder++;
|
||||
category.MaxNomineesPerUser = 3;
|
||||
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||
|
||||
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
categories.Add(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var staleCategories = categories
|
||||
.Where(item => item.Id > 0 && !usedCategories.Contains(item))
|
||||
.ToArray();
|
||||
await RemoveStaleCategoryDataAsync(db, staleCategories);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var existing = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.Select(item => item.Slug)
|
||||
private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
|
||||
{
|
||||
if (staleCategories.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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 existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var seed in SeedCatalog.CategorySeeds)
|
||||
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 (existingSlugs.Contains(seed.Slug))
|
||||
if (clip.CategoryId != null && staleCategoryIds.Contains(clip.CategoryId.Value))
|
||||
{
|
||||
continue;
|
||||
clip.CategoryId = null;
|
||||
}
|
||||
|
||||
db.Categories.Add(new Category
|
||||
if (clip.CandidateId != null && staleCandidateIds.Contains(clip.CandidateId.Value))
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
GroupName = seed.GroupName,
|
||||
Name = seed.Name,
|
||||
Slug = seed.Slug,
|
||||
Description = seed.Description,
|
||||
SortOrder = seed.SortOrder,
|
||||
MaxNomineesPerUser = 3,
|
||||
});
|
||||
clip.CandidateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
var affectedNominations = await db.Nominations
|
||||
.Where(item =>
|
||||
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value))
|
||||
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
|
||||
.ToArrayAsync();
|
||||
foreach (var nomination in affectedNominations)
|
||||
{
|
||||
if (nomination.CategoryId != null && staleCategoryIds.Contains(nomination.CategoryId.Value))
|
||||
{
|
||||
nomination.CategoryId = null;
|
||||
}
|
||||
|
||||
if (nomination.SuggestedCategoryId != null && staleCategoryIds.Contains(nomination.SuggestedCategoryId.Value))
|
||||
{
|
||||
nomination.SuggestedCategoryId = null;
|
||||
}
|
||||
|
||||
if (nomination.CandidateId != null && staleCandidateIds.Contains(nomination.CandidateId.Value))
|
||||
{
|
||||
nomination.CandidateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
var staleCandidates = await db.Candidates
|
||||
.Where(item => staleCandidateIds.Contains(item.Id))
|
||||
.ToArrayAsync();
|
||||
db.Candidates.RemoveRange(staleCandidates);
|
||||
db.Categories.RemoveRange(staleCategories);
|
||||
}
|
||||
|
||||
private static Category? FindReusableCategory(
|
||||
List<Category> categories,
|
||||
AwardCategorySeed award,
|
||||
SeasonSubcategoryTemplateSetting template,
|
||||
HashSet<Category> usedCategories)
|
||||
{
|
||||
var targetSlug = BuildCategorySlug(award.Slug, template.Slug);
|
||||
|
||||
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, targetSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.GroupName, award.Name, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.GroupName, award.LegacyGroupName, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
|
||||
@@ -142,4 +226,7 @@ public static partial class SeedDataBootstrapper
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCategorySlug(string awardSlug, string templateSlug) =>
|
||||
$"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}";
|
||||
}
|
||||
|
||||
+64
-55
@@ -1,6 +1,8 @@
|
||||
namespace Backend.Data;
|
||||
|
||||
internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder);
|
||||
using Backend.Services;
|
||||
|
||||
internal sealed record AwardCategorySeed(string LegacyGroupName, string Name, string Slug, string Description, int SortOrder);
|
||||
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||
internal sealed record SiteFaqSeed(string Question, string Answer);
|
||||
@@ -8,10 +10,17 @@ internal sealed record SiteSocialSeed(string Label, string Platform, string Url,
|
||||
|
||||
internal static class SeedCatalog
|
||||
{
|
||||
internal static readonly CategorySeed[] CategorySeeds =
|
||||
internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates =
|
||||
[
|
||||
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1),
|
||||
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2),
|
||||
new("Hidden Star", "hidden-star", 1, 1, 20),
|
||||
new("Rising Star", "rising-star", 2, 21, 60),
|
||||
new("Shining Star", "shining-star", 3, 61, null),
|
||||
];
|
||||
|
||||
internal static readonly AwardCategorySeed[] AwardCategorySeeds =
|
||||
[
|
||||
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die größte Auszeichnung des Jahres.", 1),
|
||||
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie für die Szene.", 2),
|
||||
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
|
||||
new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
|
||||
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
|
||||
@@ -34,16 +43,16 @@ internal static class SeedCatalog
|
||||
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
|
||||
new(
|
||||
"Wie funktioniert das Voting?",
|
||||
"Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."),
|
||||
"Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase."),
|
||||
new(
|
||||
"Was kostet die Teilnahme?",
|
||||
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."),
|
||||
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans."),
|
||||
new(
|
||||
"Wann und wo findet die Award-Show statt?",
|
||||
"Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"),
|
||||
"Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!"),
|
||||
new(
|
||||
"Ich wurde nominiert — was nun?",
|
||||
"Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."),
|
||||
"Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt."),
|
||||
];
|
||||
|
||||
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
|
||||
@@ -60,18 +69,18 @@ Anbieter
|
||||
VTuber Star Awards, vertreten durch Jayuhime.
|
||||
|
||||
Kontakt
|
||||
Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||
Nutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||
|
||||
Hinweis
|
||||
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.
|
||||
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.
|
||||
""";
|
||||
|
||||
internal const string DefaultContactContent = """
|
||||
Kontakt zum Award-Team
|
||||
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.
|
||||
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.
|
||||
|
||||
Datenschutzfragen
|
||||
Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||
Für Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||
|
||||
Community & Kooperationen
|
||||
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||
@@ -79,57 +88,57 @@ Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||
|
||||
internal const string DefaultSponsorsContent = """
|
||||
Sponsoren & Partner
|
||||
Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
||||
Hier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
||||
|
||||
Partner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.
|
||||
Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.
|
||||
""";
|
||||
|
||||
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
||||
[
|
||||
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
||||
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new("best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("community-liebling", "Lumi", "@lumi_vt", "Cake"),
|
||||
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||
new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||
new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||
new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||
new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||
new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||
];
|
||||
|
||||
internal static readonly WinnerSeed[] WinnerSeeds =
|
||||
[
|
||||
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
||||
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"),
|
||||
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"),
|
||||
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"),
|
||||
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"),
|
||||
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"),
|
||||
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
||||
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"),
|
||||
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"),
|
||||
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"),
|
||||
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"),
|
||||
new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||
new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"),
|
||||
new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||
new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"),
|
||||
new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"),
|
||||
new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||
new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"),
|
||||
new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||
new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||
new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||
new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"),
|
||||
new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"),
|
||||
new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
|
||||
new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,15 +49,23 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
||||
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
|
||||
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
|
||||
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
|
||||
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.",
|
||||
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
|
||||
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults),
|
||||
TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
|
||||
TrackingRulesSettings.DefaultSource,
|
||||
TrackingRulesSettings.DefaultImportantMetrics,
|
||||
TrackingRulesSettings.DefaultOptionalMetrics,
|
||||
TrackingRulesSettings.DefaultFlags)),
|
||||
ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl,
|
||||
TrackingReviewNotes = "Fallback-Quellen für manuelle Reviews:\\n- SullyGnome\\n- Twitch-Kanal direkt\\n\\nNutze diese Notizen für Edge Cases und manuelle Tier-Entscheidungen.",
|
||||
NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
|
||||
ClipSubmissionsEnabled = false,
|
||||
ClipReviewEnabled = true,
|
||||
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
|
||||
ShowactApplicationsEnabled = false,
|
||||
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
|
||||
SessionIdleTimeoutHours = 3,
|
||||
SponsorsVisible = true,
|
||||
SocialLinksJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
@@ -152,7 +160,7 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Category>().HasData(
|
||||
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die groesste Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die größte Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 },
|
||||
|
||||
@@ -16,6 +16,7 @@ public static partial class SeedDataBootstrapper
|
||||
.ToArrayAsync();
|
||||
|
||||
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
|
||||
await EnsureSeedReviewNominationsAsync(db, season, categories, candidates);
|
||||
|
||||
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
|
||||
{
|
||||
@@ -23,15 +24,15 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"),
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"),
|
||||
SubmittedByTwitchId = "local_user_3",
|
||||
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
|
||||
Title = "Starlight Debut Moment",
|
||||
Creator = "Hoshimi Miyu",
|
||||
Platform = "Twitch",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
||||
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
|
||||
@@ -40,15 +41,15 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"),
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"),
|
||||
SubmittedByTwitchId = "local_user_4",
|
||||
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
|
||||
Title = "Finale-Hype mit Chat-Chaos",
|
||||
Creator = "Kurainu",
|
||||
Platform = "Twitch",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
||||
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
|
||||
@@ -57,8 +58,8 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "best-gaming"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"),
|
||||
CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"),
|
||||
SubmittedByTwitchId = "local_user",
|
||||
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
|
||||
Title = "Epischer Clutch im Finale",
|
||||
@@ -71,15 +72,15 @@ public static partial class SeedDataBootstrapper
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "gesang-musik"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"),
|
||||
CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"),
|
||||
SubmittedByTwitchId = "local_user_2",
|
||||
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
|
||||
Title = "Live-Cover mit Gänsehaut",
|
||||
Creator = "Melo Diva",
|
||||
Platform = "YouTube",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Review-Workflow.",
|
||||
ReviewNote = "Geprüfter Clip für Review-Workflow.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
|
||||
@@ -114,7 +115,7 @@ public static partial class SeedDataBootstrapper
|
||||
EntityType = "database",
|
||||
EntityId = season.Year.ToString(),
|
||||
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
|
||||
MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }),
|
||||
MetadataJson = JsonSerializer.Serialize(new { awardCategories = SeedCatalog.AwardCategorySeeds.Length, subcategories = SeedCatalog.DefaultSubcategoryTemplates.Length }),
|
||||
CreatedFromIp = "seed",
|
||||
UserAgent = "seed-bootstrap",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
|
||||
@@ -279,5 +280,275 @@ public static partial class SeedDataBootstrapper
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.ToArray());
|
||||
|
||||
private static async Task EnsureSeedReviewNominationsAsync(
|
||||
AwardsDbContext db,
|
||||
Season season,
|
||||
IReadOnlyDictionary<string, Category> categories,
|
||||
Candidate[] candidates)
|
||||
{
|
||||
var staleSeedNominations = await db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == season.Id
|
||||
&& (
|
||||
item.SubmittedByTwitchId.StartsWith("seed_review_")
|
||||
|| item.SubmittedByTwitchId == "twitch_hoshi"
|
||||
|| item.SubmittedByTwitchId == "twitch_kurainu"
|
||||
|| item.SubmittedByTwitchId.StartsWith("demo_user")
|
||||
|| item.SubmittedByTwitchId.StartsWith("local_user")
|
||||
))
|
||||
.ToArrayAsync();
|
||||
|
||||
if (staleSeedNominations.Length > 0)
|
||||
{
|
||||
db.Nominations.RemoveRange(staleSeedNominations);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var orderedCategories = categories.Values
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToArray();
|
||||
var candidatesByCategoryId = candidates
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.ToDictionary(
|
||||
grouping => grouping.Key,
|
||||
grouping => grouping.OrderBy(item => item.DisplayName).ToArray());
|
||||
|
||||
var seedNominations = new List<Nomination>();
|
||||
var createdAt = new DateTimeOffset(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
foreach (var category in orderedCategories)
|
||||
{
|
||||
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
||||
var existingCandidate = categoryCandidates?.FirstOrDefault();
|
||||
|
||||
seedNominations.AddRange(BuildPendingSeedGroup(
|
||||
category,
|
||||
existingCandidate,
|
||||
groupKey: "existing",
|
||||
firstSubmitter: $"seed_review_{category.Slug}_existing_a",
|
||||
secondSubmitter: $"seed_review_{category.Slug}_existing_b",
|
||||
createdAt,
|
||||
useSuggestedCategory: true,
|
||||
trackerStatus: "resolved"));
|
||||
createdAt = createdAt.AddMinutes(8);
|
||||
|
||||
seedNominations.AddRange(BuildPendingSeedGroup(
|
||||
category,
|
||||
existingCandidate: null,
|
||||
groupKey: "fresh",
|
||||
firstSubmitter: $"seed_review_{category.Slug}_fresh_a",
|
||||
secondSubmitter: $"seed_review_{category.Slug}_fresh_b",
|
||||
createdAt,
|
||||
useSuggestedCategory: false,
|
||||
trackerStatus: "unsupported_platform"));
|
||||
createdAt = createdAt.AddMinutes(8);
|
||||
}
|
||||
|
||||
foreach (var category in orderedCategories.Take(2))
|
||||
{
|
||||
seedNominations.AddRange(BuildReviewedSeedGroup(
|
||||
category,
|
||||
status: "rejected",
|
||||
displayName: $"{category.GroupName} Review Return",
|
||||
submittedByPrefix: $"seed_review_{category.Slug}_rejected",
|
||||
createdAt,
|
||||
reviewedByTwitchId: "jayuhime_admin",
|
||||
candidateId: null,
|
||||
candidateDisplayName: null));
|
||||
createdAt = createdAt.AddMinutes(10);
|
||||
}
|
||||
|
||||
foreach (var category in orderedCategories.Skip(2).Take(2))
|
||||
{
|
||||
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
||||
var candidate = categoryCandidates?.FirstOrDefault();
|
||||
seedNominations.AddRange(BuildReviewedSeedGroup(
|
||||
category,
|
||||
status: "approved",
|
||||
displayName: candidate?.DisplayName ?? $"{category.GroupName} Approved Pick",
|
||||
submittedByPrefix: $"seed_review_{category.Slug}_approved",
|
||||
createdAt,
|
||||
reviewedByTwitchId: "jayuhime_admin",
|
||||
candidateId: candidate?.Id,
|
||||
candidateDisplayName: candidate?.DisplayName));
|
||||
createdAt = createdAt.AddMinutes(10);
|
||||
}
|
||||
|
||||
db.Nominations.AddRange(seedNominations);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static IEnumerable<Nomination> BuildPendingSeedGroup(
|
||||
Category category,
|
||||
Candidate? existingCandidate,
|
||||
string groupKey,
|
||||
string firstSubmitter,
|
||||
string secondSubmitter,
|
||||
DateTimeOffset createdAt,
|
||||
bool useSuggestedCategory,
|
||||
string trackerStatus)
|
||||
{
|
||||
var displayName = existingCandidate?.DisplayName ?? BuildFreshSeedName(category, groupKey);
|
||||
var platform = existingCandidate?.Platform ?? "YouTube";
|
||||
var channelSlug = existingCandidate?.ChannelSlug ?? BuildSeedChannelSlug(category, groupKey);
|
||||
var streamUrl = BuildSeedStreamUrl(platform, channelSlug);
|
||||
var avgViewers = ResolveSeedViewerValue(category);
|
||||
int? suggestedCategoryId = useSuggestedCategory ? category.Id : null;
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = firstSubmitter,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = platform,
|
||||
AvgViewers = avgViewers,
|
||||
SuggestedCategoryId = suggestedCategoryId,
|
||||
TrackerStatus = trackerStatus,
|
||||
TrackerCheckedAt = createdAt.AddMinutes(2),
|
||||
TrackingReviewStatus = "clear",
|
||||
Status = "pending",
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = secondSubmitter,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = platform,
|
||||
AvgViewers = avgViewers,
|
||||
SuggestedCategoryId = suggestedCategoryId,
|
||||
TrackerStatus = trackerStatus,
|
||||
TrackerCheckedAt = createdAt.AddMinutes(3),
|
||||
TrackingReviewStatus = "clear",
|
||||
Status = "pending",
|
||||
CreatedAt = createdAt.AddMinutes(1),
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<Nomination> BuildReviewedSeedGroup(
|
||||
Category category,
|
||||
string status,
|
||||
string displayName,
|
||||
string submittedByPrefix,
|
||||
DateTimeOffset createdAt,
|
||||
string reviewedByTwitchId,
|
||||
int? candidateId,
|
||||
string? candidateDisplayName)
|
||||
{
|
||||
var channelSlug = BuildSeedChannelSlug(category, $"{status}_{displayName}");
|
||||
var streamUrl = BuildSeedStreamUrl("Twitch", channelSlug);
|
||||
var reviewNote = status == "approved"
|
||||
? "Seed-Datensatz: bereits als Kandidat übernommen."
|
||||
: "Seed-Datensatz: bewusst verworfen für Undo-Tests.";
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = $"{submittedByPrefix}_a",
|
||||
CandidateId = candidateId,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = "Twitch",
|
||||
AvgViewers = ResolveSeedViewerValue(category),
|
||||
SuggestedCategoryId = category.Id,
|
||||
TrackerStatus = "resolved",
|
||||
TrackerCheckedAt = createdAt.AddMinutes(2),
|
||||
TrackingReviewStatus = "reviewed",
|
||||
TrackingReviewNote = reviewNote,
|
||||
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
||||
TrackingReviewedAt = createdAt.AddMinutes(4),
|
||||
Status = status,
|
||||
ReviewNote = reviewNote,
|
||||
ReviewedByTwitchId = reviewedByTwitchId,
|
||||
CreatedAt = createdAt,
|
||||
ReviewedAt = createdAt.AddMinutes(4),
|
||||
};
|
||||
|
||||
yield return new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
CategoryGroupName = category.GroupName,
|
||||
SubmittedByTwitchId = $"{submittedByPrefix}_b",
|
||||
CandidateId = candidateId,
|
||||
CandidateText = displayName,
|
||||
StreamUrl = streamUrl,
|
||||
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||
ResolvedPlatform = "Twitch",
|
||||
AvgViewers = ResolveSeedViewerValue(category),
|
||||
SuggestedCategoryId = category.Id,
|
||||
TrackerStatus = "resolved",
|
||||
TrackerCheckedAt = createdAt.AddMinutes(3),
|
||||
TrackingReviewStatus = "reviewed",
|
||||
TrackingReviewNote = reviewNote,
|
||||
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
||||
TrackingReviewedAt = createdAt.AddMinutes(5),
|
||||
Status = status,
|
||||
ReviewNote = reviewNote,
|
||||
ReviewedByTwitchId = reviewedByTwitchId,
|
||||
CreatedAt = createdAt.AddMinutes(1),
|
||||
ReviewedAt = createdAt.AddMinutes(5),
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildFreshSeedName(Category category, string groupKey) =>
|
||||
groupKey switch
|
||||
{
|
||||
"fresh" => $"{category.GroupName} Spotlight {category.Name}",
|
||||
_ => $"{category.GroupName} {category.Name} Pick",
|
||||
};
|
||||
|
||||
private static string BuildSeedChannelSlug(Category category, string suffix)
|
||||
{
|
||||
var raw = $"{category.Slug}-{suffix}"
|
||||
.Trim()
|
||||
.TrimStart('@')
|
||||
.ToLowerInvariant();
|
||||
|
||||
return new string(raw.Where(char.IsLetterOrDigit).ToArray());
|
||||
}
|
||||
|
||||
private static string BuildSeedStreamUrl(string platform, string channelSlug) =>
|
||||
platform.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"youtube" => $"https://www.youtube.com/@{channelSlug}",
|
||||
"kick" => $"https://kick.com/{channelSlug}",
|
||||
"cake" => $"https://cake.gg/{channelSlug}",
|
||||
_ => $"https://www.twitch.tv/{channelSlug}",
|
||||
};
|
||||
|
||||
private static int ResolveSeedViewerValue(Category category)
|
||||
{
|
||||
if (category.ViewerRangeMin is int min && category.ViewerRangeMax is int max)
|
||||
{
|
||||
return min + ((max - min) / 2);
|
||||
}
|
||||
|
||||
if (category.ViewerRangeMin is int lowerBound)
|
||||
{
|
||||
return lowerBound + 12;
|
||||
}
|
||||
|
||||
if (category.ViewerRangeMax is int upperBound)
|
||||
{
|
||||
return Math.Max(1, upperBound - 5);
|
||||
}
|
||||
|
||||
return 25;
|
||||
}
|
||||
|
||||
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,34 @@ public static partial class SeedDataBootstrapper
|
||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults);
|
||||
}
|
||||
|
||||
if (!HasValidTrackingRules(settings.TrackingRulesJson))
|
||||
{
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
|
||||
TrackingRulesSettings.DefaultSource,
|
||||
TrackingRulesSettings.DefaultImportantMetrics,
|
||||
TrackingRulesSettings.DefaultOptionalMetrics,
|
||||
TrackingRulesSettings.DefaultFlags));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.ViewerStatsProviderBaseUrl))
|
||||
{
|
||||
settings.ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.TrackingReviewNotes))
|
||||
{
|
||||
settings.TrackingReviewNotes = """
|
||||
Fallback-Quellen für manuelle Reviews:
|
||||
- SullyGnome Channel Summary
|
||||
- Offizieller Twitch-Kanal
|
||||
|
||||
Prüfe bei Edge Cases:
|
||||
- passt der Kanal wirklich zur Unterkategorie?
|
||||
- fehlen TwitchTracker-Daten nur temporär?
|
||||
- braucht der Fall eine manuelle Team-Notiz?
|
||||
""";
|
||||
}
|
||||
|
||||
if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
|
||||
{
|
||||
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
|
||||
@@ -83,7 +111,12 @@ public static partial class SeedDataBootstrapper
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
|
||||
{
|
||||
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.";
|
||||
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.";
|
||||
}
|
||||
|
||||
if (settings.SessionIdleTimeoutHours < 3)
|
||||
{
|
||||
settings.SessionIdleTimeoutHours = 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +154,14 @@ public static partial class SeedDataBootstrapper
|
||||
private static bool HasValidWorkflowRules(string? json) =>
|
||||
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length;
|
||||
|
||||
private static bool HasValidTrackingRules(string? json)
|
||||
{
|
||||
var rules = TrackingRulesSettings.Read(new Backend.Domain.SiteSettings { TrackingRulesJson = json ?? string.Empty });
|
||||
return rules.ImportantMetrics.Length == TrackingRulesSettings.DefaultImportantMetrics.Length
|
||||
&& rules.OptionalMetrics.Length == TrackingRulesSettings.DefaultOptionalMetrics.Length
|
||||
&& rules.Flags.Length == TrackingRulesSettings.DefaultFlags.Length;
|
||||
}
|
||||
|
||||
private static bool HasValidNominationLinkBlacklist(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
||||
@@ -7,9 +7,12 @@ public sealed class Candidate
|
||||
public Season Season { get; set; } = null!;
|
||||
public int CategoryId { get; set; }
|
||||
public Category Category { get; set; } = null!;
|
||||
public int? StreamerIdentityId { get; set; }
|
||||
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string ChannelSlug { get; set; } = string.Empty;
|
||||
public string Platform { get; set; } = "Twitch";
|
||||
public int NominationTally { get; set; }
|
||||
public string AcceptanceStatus { get; set; } = "open";
|
||||
public string? AcceptanceNote { get; set; }
|
||||
public string? ClipCompilationUrl { get; set; }
|
||||
|
||||
@@ -11,5 +11,7 @@ public sealed class Category
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public int MaxNomineesPerUser { get; set; }
|
||||
public int? ViewerRangeMin { get; set; }
|
||||
public int? ViewerRangeMax { get; set; }
|
||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -5,13 +5,32 @@ public sealed class Nomination
|
||||
public int Id { get; set; }
|
||||
public int SeasonId { get; set; }
|
||||
public Season Season { get; set; } = null!;
|
||||
public int CategoryId { get; set; }
|
||||
public Category Category { get; set; } = null!;
|
||||
public int? CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
public string CategoryGroupName { get; set; } = string.Empty;
|
||||
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
||||
public int? CandidateId { get; set; }
|
||||
public Candidate? Candidate { get; set; }
|
||||
public int? StreamerIdentityId { get; set; }
|
||||
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||
public int? SuggestedCategoryId { get; set; }
|
||||
public Category? SuggestedCategory { get; set; }
|
||||
public string? CandidateText { get; set; }
|
||||
public string? StreamUrl { get; set; }
|
||||
public string? ResolvedChannel { get; set; }
|
||||
public string? ResolvedPlatform { get; set; }
|
||||
public int? AvgViewers { get; set; }
|
||||
public int? HoursStreamed { get; set; }
|
||||
public int? HoursWatched { get; set; }
|
||||
public int? PeakViewers { get; set; }
|
||||
public int? FollowersGained { get; set; }
|
||||
public string TrackerStatus { get; set; } = "pending";
|
||||
public DateTimeOffset? TrackerCheckedAt { get; set; }
|
||||
public string TrackingReviewStatus { get; set; } = "clear";
|
||||
public string TrackingFlagsJson { get; set; } = "[]";
|
||||
public string? TrackingReviewNote { get; set; }
|
||||
public string? TrackingReviewedByTwitchId { get; set; }
|
||||
public DateTimeOffset? TrackingReviewedAt { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? ReviewNote { get; set; }
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class Season
|
||||
public DateOnly ReviewEndsAt { get; set; }
|
||||
public DateOnly ShowDate { get; set; }
|
||||
public TimeOnly ShowStartsAt { get; set; } = new(20, 0);
|
||||
public string SubcategoryTemplatesJson { get; set; } = "[]";
|
||||
public string WorkflowRulesJson { get; set; } = "[]";
|
||||
public ICollection<Category> Categories { get; set; } = [];
|
||||
public ICollection<AwardResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class ShowactApplication
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string TechnicalNotes { get; set; } = string.Empty;
|
||||
public string ReferenceUrl { get; set; } = string.Empty;
|
||||
public string FieldResponsesJson { get; set; } = "{}";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? ReviewNote { get; set; }
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class SiteSettings
|
||||
public string HostDisplayName { get; set; } = string.Empty;
|
||||
public string HostTagline { get; set; } = string.Empty;
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ShareXUrl { get; set; } = string.Empty;
|
||||
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||
public string PrivacyEmail { get; set; } = string.Empty;
|
||||
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
||||
public string? PrivacyPolicyUpdatedBy { get; set; }
|
||||
@@ -22,13 +24,19 @@ public sealed class SiteSettings
|
||||
public string FaqJson { get; set; } = "[]";
|
||||
public string RiskRulesJson { get; set; } = "[]";
|
||||
public string WorkflowRulesJson { get; set; } = "[]";
|
||||
public string TrackingRulesJson { get; set; } = "[]";
|
||||
public string ViewerStatsProviderBaseUrl { get; set; } = string.Empty;
|
||||
public string TrackingReviewNotes { get; set; } = string.Empty;
|
||||
public string NominationLinkBlacklistJson { get; set; } = "[]";
|
||||
public bool ClipSubmissionsEnabled { get; set; }
|
||||
public bool ClipReviewEnabled { get; set; } = true;
|
||||
public bool ClipAdminMenuVisible { get; set; } = true;
|
||||
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||
public bool ShowactApplicationsEnabled { get; set; }
|
||||
public DateOnly? ShowactApplicationStartsAt { get; set; }
|
||||
public DateOnly? ShowactApplicationEndsAt { get; set; }
|
||||
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
|
||||
public string ShowactFormSchemaJson { get; set; } = "[]";
|
||||
public bool SponsorsVisible { get; set; } = true;
|
||||
public bool DemoLoginManagedByDatabase { get; set; }
|
||||
public bool DemoLoginEnabled { get; set; }
|
||||
@@ -42,6 +50,7 @@ public sealed class SiteSettings
|
||||
public string TwitchClientSecret { get; set; } = string.Empty;
|
||||
public string TwitchRedirectUri { get; set; } = string.Empty;
|
||||
public string TwitchScope { get; set; } = string.Empty;
|
||||
public int SessionIdleTimeoutHours { get; set; } = 3;
|
||||
public bool MaintenanceModeEnabled { get; set; }
|
||||
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
||||
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class StreamerIdentity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
public string Login { get; set; } = string.Empty;
|
||||
public string NormalizedKey { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? ProfileUrl { get; set; }
|
||||
public DateTimeOffset? LastResolvedAt { get; set; }
|
||||
public ICollection<Nomination> Nominations { get; set; } = [];
|
||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||
}
|
||||
@@ -303,7 +303,8 @@ public static class AdminExtrasEndpoints
|
||||
application.Status,
|
||||
application.ReviewNote,
|
||||
application.CreatedAt,
|
||||
application.ReviewedAt);
|
||||
application.ReviewedAt,
|
||||
application.FieldResponsesJson ?? "{}");
|
||||
|
||||
private static string NormalizeStatus(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
||||
|
||||
@@ -20,6 +20,14 @@ public static partial class AdminModerationEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("ReopenRejectedAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("UpdateNominationTrackingReview")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("GetAdminNominationLinkBlacklist")
|
||||
|
||||
@@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations
|
||||
.Include(item => item.Category)
|
||||
.Include(item => item.SuggestedCategory)
|
||||
.Include(item => item.StreamerIdentity)
|
||||
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||
|
||||
if (nomination is null)
|
||||
@@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Any(flag => flag.BlocksApproval)
|
||||
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
|
||||
}
|
||||
|
||||
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
||||
}
|
||||
|
||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
||||
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
|
||||
if (!categoryId.HasValue)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
|
||||
}
|
||||
|
||||
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||
item.Id == categoryId.Value
|
||||
&& item.SeasonId == nomination.SeasonId
|
||||
&& item.GroupName == nomination.CategoryGroupName);
|
||||
if (targetCategory is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
|
||||
}
|
||||
|
||||
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform)
|
||||
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
|
||||
: request.Platform.Trim();
|
||||
var normalizedDisplayName = rawDisplayName.ToLower();
|
||||
var normalizedChannelSlug = channelSlug.ToLower();
|
||||
var normalizedPlatform = platform.ToLower();
|
||||
|
||||
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryId == nomination.CategoryId
|
||||
&& item.CategoryId == targetCategory.Id
|
||||
&& (
|
||||
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
||
|
||||
item.DisplayName.ToLower() == normalizedDisplayName
|
||||
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||
&& item.Platform.ToLower() == normalizedPlatform)
|
||||
));
|
||||
|
||||
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
nomination.SeasonId,
|
||||
targetCategory.Id,
|
||||
existingCandidate?.Id,
|
||||
nomination.StreamerIdentityId,
|
||||
rawDisplayName,
|
||||
channelSlug,
|
||||
existingCandidate?.AcceptanceStatus ?? "open",
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
var candidate = existingCandidate;
|
||||
if (candidate is null)
|
||||
{
|
||||
candidate = new Candidate
|
||||
{
|
||||
SeasonId = nomination.SeasonId,
|
||||
CategoryId = nomination.CategoryId,
|
||||
CategoryId = targetCategory.Id,
|
||||
StreamerIdentityId = nomination.StreamerIdentityId,
|
||||
DisplayName = rawDisplayName,
|
||||
ChannelSlug = channelSlug,
|
||||
Platform = platform,
|
||||
@@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
|
||||
candidate.DisplayName = rawDisplayName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||
@@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
nomination.CandidateId = candidate.Id;
|
||||
nomination.Status = "approved";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var uniqueViewerCount = relatedNominations
|
||||
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
|
||||
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = candidate.Id;
|
||||
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
|
||||
relatedNomination.Status = "approved";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.approve",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
|
||||
new
|
||||
{
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
targetCategoryId = targetCategory.Id,
|
||||
targetCategoryName = targetCategory.Name,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
uniqueViewerCount,
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
nominationId = nomination.Id,
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
uniqueViewerCount,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectNomination(
|
||||
@@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
nomination.CandidateId = null;
|
||||
nomination.Status = "rejected";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "rejected";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.",
|
||||
new { nomination.ReviewNote },
|
||||
new
|
||||
{
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReopenRejectedNomination(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
ReopenRejectedNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "pending";
|
||||
relatedNomination.ReviewNote = null;
|
||||
relatedNomination.ReviewedAt = null;
|
||||
relatedNomination.ReviewedByTwitchId = null;
|
||||
}
|
||||
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.reopen",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.",
|
||||
new
|
||||
{
|
||||
reviewNote,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateNominationTrackingReview(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
UpdateNominationTrackingReviewRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedStatus = request.Status?.Trim().ToLowerInvariant();
|
||||
if (normalizedStatus is not ("reviewed" or "overridden"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
var requiresOverrideNote = relatedNominations
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.Any(flag => flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." });
|
||||
}
|
||||
|
||||
foreach (var item in relatedNominations)
|
||||
{
|
||||
item.TrackingReviewStatus = normalizedStatus;
|
||||
item.TrackingReviewNote = reviewNote;
|
||||
item.TrackingReviewedByTwitchId = session.TwitchUserId;
|
||||
item.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.tracking-review.update",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.",
|
||||
new
|
||||
{
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
status = normalizedStatus,
|
||||
reviewNote,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId, status = normalizedStatus });
|
||||
}
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedPendingNominationsAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
CancellationToken cancellationToken)
|
||||
=> await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken);
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedNominationsByStatusAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
string status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryGroupName == nomination.CategoryGroupName
|
||||
&& item.Status == status);
|
||||
|
||||
if (nomination.StreamerIdentityId.HasValue)
|
||||
{
|
||||
return await query
|
||||
.Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedStreamUrl))
|
||||
{
|
||||
var rows = await query.ToArrayAsync(cancellationToken);
|
||||
return rows
|
||||
.Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [nomination];
|
||||
}
|
||||
|
||||
private static string NormalizeModerationStreamUrl(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values) =>
|
||||
values
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||
?? string.Empty;
|
||||
|
||||
private static async Task<IResult?> BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
int? streamerIdentityId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
var rules = WorkflowRuleSettings.Read(season, settings);
|
||||
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingCandidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
|
||||
&& item.AcceptanceStatus != "declined")
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||
{
|
||||
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||
if (categoryCount >= finalistsRule.Limit)
|
||||
{
|
||||
return CreateModerationWorkflowRuleError(
|
||||
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
|
||||
}
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateModerationWorkflowRuleError(
|
||||
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult CreateModerationWorkflowRuleError(string message) =>
|
||||
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||
|
||||
private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Length == 0)
|
||||
{
|
||||
nomination.TrackingReviewStatus = "clear";
|
||||
nomination.TrackingReviewNote = null;
|
||||
nomination.TrackingReviewedByTwitchId = null;
|
||||
nomination.TrackingReviewedAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim();
|
||||
nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note))
|
||||
? "overridden"
|
||||
: "reviewed";
|
||||
nomination.TrackingReviewNote = note;
|
||||
nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId;
|
||||
nomination.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
seasonId,
|
||||
request.CategoryId,
|
||||
null,
|
||||
null,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
@@ -158,6 +159,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
candidate.SeasonId,
|
||||
request.CategoryId,
|
||||
candidateId,
|
||||
candidate.StreamerIdentityId,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
|
||||
@@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = request.ViewerRangeMin,
|
||||
ViewerRangeMax = request.ViewerRangeMax,
|
||||
};
|
||||
|
||||
db.Categories.Add(category);
|
||||
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = request.ViewerRangeMin;
|
||||
category.ViewerRangeMax = request.ViewerRangeMax;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> UpdateSeasonSubcategoryTemplates(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateSeasonSubcategoryTemplatesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateSubcategoryTemplatesRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
var blockedRemovals = await FindBlockedSubcategoryRemovalsAsync(db, categories, templates, context.RequestAborted);
|
||||
if (blockedRemovals.Length > 0)
|
||||
{
|
||||
var firstBlocked = blockedRemovals[0];
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = $"Unterkategorie \"{firstBlocked.SubcategoryName}\" kann nicht entfernt werden, weil darunter noch {firstBlocked.CandidateCount} Kandidaten und {firstBlocked.NominationCount} Nominierungen haengen.",
|
||||
blockedSubcategories = blockedRemovals,
|
||||
});
|
||||
}
|
||||
|
||||
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-templates.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Unterkategorien für {season.Year} wurden aktualisiert.",
|
||||
new { seasonId, templateCount = templates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, templateCount = templates.Length });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Lege zuerst mindestens eine globale Unterkategorie an." });
|
||||
}
|
||||
|
||||
var groupName = request.GroupName.Trim();
|
||||
if (categories.Any(item => string.Equals(item.GroupName, groupName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
categories.Add(new Category
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
GroupName = groupName,
|
||||
Name = templates[index].Name,
|
||||
Slug = BuildCategorySlug(groupName, templates[index].Slug),
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = templates[index].ViewerRangeMin,
|
||||
ViewerRangeMax = templates[index].ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var category in categories.Where(item => item.Id == 0))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.create",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {groupName} wurde angelegt.",
|
||||
new { seasonId, groupName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
var normalizedCurrentName = groupName.Trim();
|
||||
var groupCategories = categories
|
||||
.Where(item => string.Equals(item.GroupName, normalizedCurrentName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var targetName = request.GroupName.Trim();
|
||||
if (!string.Equals(normalizedCurrentName, targetName, StringComparison.OrdinalIgnoreCase)
|
||||
&& categories.Any(item => string.Equals(item.GroupName, targetName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
foreach (var category in groupCategories)
|
||||
{
|
||||
category.GroupName = targetName;
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedCurrentName} wurde aktualisiert.",
|
||||
new { seasonId, from = normalizedCurrentName, to = targetName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName = targetName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var normalizedGroupName = groupName.Trim();
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName.ToLower() == normalizedGroupName.ToLower())
|
||||
.ToListAsync(context.RequestAborted);
|
||||
if (categories.Count == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categoryIds = categories.Select(item => item.Id).ToArray();
|
||||
var candidates = await db.Candidates
|
||||
.Where(item => categoryIds.Contains(item.CategoryId))
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
|
||||
db.Categories.RemoveRange(categories);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.delete",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedGroupName} wurde gelöscht.",
|
||||
new { seasonId, removedCategories = categories.Count, removedCandidates = candidates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, seasonId, groupName = normalizedGroupName });
|
||||
}
|
||||
|
||||
private static void SyncCategoryGroupsToTemplates(
|
||||
AwardsDbContext db,
|
||||
Season season,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates)
|
||||
{
|
||||
var orderedGroups = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var items = group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList();
|
||||
var sample = items[0];
|
||||
return new
|
||||
{
|
||||
GroupName = sample.GroupName.Trim(),
|
||||
Description = sample.Description.Trim(),
|
||||
SortOrder = items.Min(item => item.SortOrder),
|
||||
MaxNomineesPerUser = sample.MaxNomineesPerUser,
|
||||
Items = items,
|
||||
};
|
||||
})
|
||||
.OrderBy(group => group.SortOrder)
|
||||
.ThenBy(group => group.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var nextSortOrder = 1;
|
||||
foreach (var group in orderedGroups)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
var template = templates[index];
|
||||
var category = FindReusableCategoryForTemplate(group.Items, template, group.GroupName, usedCategories)
|
||||
?? new Category { SeasonId = season.Id };
|
||||
usedCategories.Add(category);
|
||||
|
||||
category.GroupName = group.GroupName;
|
||||
category.Name = template.Name;
|
||||
category.Slug = BuildCategorySlug(group.GroupName, template.Slug);
|
||||
category.Description = group.Description;
|
||||
category.MaxNomineesPerUser = group.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||
category.SortOrder = nextSortOrder++;
|
||||
|
||||
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
categories.Add(category);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var staleCategory in FindStaleCategoriesAfterTemplateSync(group.Items, templates, group.GroupName))
|
||||
{
|
||||
RemoveCategoryWithCandidates(db, staleCategory);
|
||||
categories.Remove(staleCategory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveCategoryWithCandidates(AwardsDbContext db, Category category)
|
||||
{
|
||||
if (category.Id > 0)
|
||||
{
|
||||
var candidates = db.Candidates.Where(item => item.CategoryId == category.Id).ToArray();
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
db.Categories.Remove(category);
|
||||
}
|
||||
|
||||
private static async Task<BlockedSubcategoryRemoval[]> FindBlockedSubcategoryRemovalsAsync(
|
||||
AwardsDbContext db,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var staleCategories = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(group => FindStaleCategoriesAfterTemplateSync(
|
||||
group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(),
|
||||
templates,
|
||||
group.Key))
|
||||
.Where(item => item.Id > 0)
|
||||
.ToArray();
|
||||
|
||||
if (staleCategories.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
|
||||
var candidateCounts = await db.Candidates
|
||||
.Where(item => staleCategoryIds.Contains(item.CategoryId))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
var nominationCounts = await db.Nominations
|
||||
.Where(item =>
|
||||
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value)))
|
||||
.GroupBy(item => item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)
|
||||
? item.CategoryId!.Value
|
||||
: item.SuggestedCategoryId!.Value)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
|
||||
return staleCategories
|
||||
.Select(category => new BlockedSubcategoryRemoval(
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Slug,
|
||||
candidateCounts.GetValueOrDefault(category.Id),
|
||||
nominationCounts.GetValueOrDefault(category.Id)))
|
||||
.Where(item => item.CandidateCount > 0 || item.NominationCount > 0)
|
||||
.OrderBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.SubcategoryName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Category? FindReusableCategoryForTemplate(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting template,
|
||||
string groupName,
|
||||
HashSet<Category>? usedCategories = null)
|
||||
{
|
||||
usedCategories ??= [];
|
||||
var expectedSlug = BuildCategorySlug(groupName, template.Slug);
|
||||
var normalizedTemplateSlug = SeasonSubcategoryTemplateSettings.Slugify(template.Slug);
|
||||
|
||||
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, expectedSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, normalizedTemplateSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& item.Slug.EndsWith($"-{normalizedTemplateSlug}", StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static Category[] FindStaleCategoriesAfterTemplateSync(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
string groupName)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var reusableCategory = FindReusableCategoryForTemplate(categories, template, groupName, usedCategories);
|
||||
if (reusableCategory is not null)
|
||||
{
|
||||
usedCategories.Add(reusableCategory);
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
.Where(item => !usedCategories.Contains(item))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IResult? ValidateSubcategoryTemplatesRequest(UpdateSeasonSubcategoryTemplatesRequest request)
|
||||
{
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Mindestens eine Unterkategorie ist erforderlich." });
|
||||
}
|
||||
|
||||
var duplicateNames = templates
|
||||
.GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateNames)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Namen mehrfach verwenden." });
|
||||
}
|
||||
|
||||
var duplicateSlugs = templates
|
||||
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateSlugs)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Slug mehrfach verwenden." });
|
||||
}
|
||||
|
||||
foreach (var template in templates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Name ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(template.Slug) || template.Slug.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Slug ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (template.ViewerRangeMax is not null
|
||||
&& template.ViewerRangeMin is not null
|
||||
&& template.ViewerRangeMax < template.ViewerRangeMin)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer-Range Ende muss groesser oder gleich dem Start sein." });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult? ValidateCategoryGroupRequest(UpsertCategoryGroupRequest request)
|
||||
{
|
||||
var groupName = request.GroupName.Trim();
|
||||
var description = request.Description.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > 80)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Hauptkategorie ist erforderlich und muss unter 80 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (description.Length > 400)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Beschreibung muss unter 400 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (request.SortOrder is < 1 or > 200)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Die Reihenfolge muss zwischen 1 und 200 liegen." });
|
||||
}
|
||||
|
||||
if (request.MaxNomineesPerUser is < 1 or > 10)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das Nominierungs-Limit muss zwischen 1 und 10 liegen." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record BlockedSubcategoryRemoval(
|
||||
string GroupName,
|
||||
string SubcategoryName,
|
||||
string Slug,
|
||||
int CandidateCount,
|
||||
int NominationCount);
|
||||
|
||||
private static string BuildCategorySlug(string groupName, string templateSlug)
|
||||
{
|
||||
var groupSlug = SeasonSubcategoryTemplateSettings.Slugify(groupName);
|
||||
var detailSlug = SeasonSubcategoryTemplateSettings.Slugify(templateSlug);
|
||||
return string.IsNullOrWhiteSpace(groupSlug) ? detailSlug : $"{groupSlug}-{detailSlug}";
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
}
|
||||
|
||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings));
|
||||
|
||||
var season = new Season
|
||||
{
|
||||
@@ -45,6 +49,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
ReviewEndsAt = request.ReviewEndsAt,
|
||||
ShowDate = request.ShowDate,
|
||||
ShowStartsAt = request.ShowStartsAt,
|
||||
SubcategoryTemplatesJson = "[]",
|
||||
WorkflowRulesJson = initialWorkflowRulesJson,
|
||||
};
|
||||
|
||||
db.Seasons.Add(season);
|
||||
@@ -72,6 +78,13 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
copiedCategoryCount = sourceCategories.Length;
|
||||
var sourceSeason = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||
season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson;
|
||||
season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson)
|
||||
? initialWorkflowRulesJson
|
||||
: sourceSeason.WorkflowRulesJson;
|
||||
foreach (var category in sourceCategories)
|
||||
{
|
||||
db.Categories.Add(new Category
|
||||
@@ -83,6 +96,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = category.Description,
|
||||
SortOrder = category.SortOrder,
|
||||
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||
|
||||
var candidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new AdminCandidateItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.Platform,
|
||||
item.NominationTally,
|
||||
item.AcceptanceStatus,
|
||||
item.AcceptanceNote,
|
||||
item.ClipCompilationUrl,
|
||||
@@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
|
||||
season,
|
||||
categoryRows.Select(category => new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
}));
|
||||
var visibleCategoryRows = categoryRows
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
|
||||
new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
},
|
||||
subcategoryTemplateSettings))
|
||||
.ToArray();
|
||||
|
||||
var categories = categoryRows
|
||||
var categories = visibleCategoryRows
|
||||
.Select(category => new AdminCategoryItemDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
@@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominations = await db.Nominations
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||
|
||||
var pendingNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? string.Empty,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = await db.Nominations
|
||||
var pendingNominations = pendingNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
|
||||
|
||||
var reviewedNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = reviewedNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var resultItems = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CandidateId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.Platform))
|
||||
@@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
season.ReviewEndsAt,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
subcategoryTemplates,
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations,
|
||||
pendingNominationGroups,
|
||||
reviewedNominations,
|
||||
settings?.TrackingReviewNotes ?? string.Empty,
|
||||
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||
resultItems,
|
||||
clipSubmissions));
|
||||
}
|
||||
|
||||
private sealed record AdminNominationRow(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string? CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? HoursStreamed,
|
||||
int? HoursWatched,
|
||||
int? PeakViewers,
|
||||
int? FollowersGained,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
string TrackingFlagsJson,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
string? CandidateDisplayName,
|
||||
string? ReviewNote,
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
private static AdminNominationReviewItemDto ToNominationReviewItem(
|
||||
AdminNominationRow item,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
|
||||
.Select(ToTrackingFlagHitDto)
|
||||
.ToArray();
|
||||
|
||||
return new(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
ResolveCategoryGroupName(item, categoryRows),
|
||||
ResolveCategoryName(item, categoryRows),
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryName,
|
||||
item.StreamerIdentityId,
|
||||
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
|
||||
trackingFlags.Any(flag => flag.RequiresManualReview),
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(item, trackingRules),
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
item.CandidateDisplayName,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt);
|
||||
}
|
||||
|
||||
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
|
||||
IEnumerable<AdminNominationRow> rows,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
rows
|
||||
.GroupBy(item => new
|
||||
{
|
||||
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
|
||||
IdentityKey = item.StreamerIdentityId.HasValue
|
||||
? $"identity:{item.StreamerIdentityId.Value}"
|
||||
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
|
||||
})
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
|
||||
var representative = ordered
|
||||
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
|
||||
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
|
||||
.ThenByDescending(item => item.AvgViewers.HasValue)
|
||||
.First();
|
||||
var trackerStatus = ResolveGroupTrackerStatus(ordered);
|
||||
var trackingFlags = ordered
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.GroupBy(item => item.Key)
|
||||
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
|
||||
.ToArray();
|
||||
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
|
||||
return new AdminNominationReviewGroupDto(
|
||||
representative.Id,
|
||||
ordered.Select(item => item.Id).ToArray(),
|
||||
ResolveCategoryGroupName(representative, categoryRows),
|
||||
ResolveNominationDisplayName(representative),
|
||||
representative.StreamUrl,
|
||||
representative.ResolvedChannel,
|
||||
representative.ResolvedPlatform,
|
||||
representative.AvgViewers,
|
||||
representative.SuggestedCategoryId,
|
||||
representative.SuggestedCategoryName,
|
||||
representative.StreamerIdentityId,
|
||||
trackerStatus,
|
||||
representative.TrackerCheckedAt,
|
||||
ResolveGroupTrackingReviewStatus(ordered),
|
||||
requiresManualReview,
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(representative, trackingRules),
|
||||
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
|
||||
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
|
||||
ordered.Max(item => item.TrackingReviewedAt),
|
||||
ordered.Length,
|
||||
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
|
||||
ordered.First().CreatedAt,
|
||||
ordered.Last().CreatedAt);
|
||||
})
|
||||
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
|
||||
.ThenByDescending(item => item.RequiresManualReview)
|
||||
.ThenByDescending(item => item.NominationTally)
|
||||
.ThenByDescending(item => item.UniqueSubmitterCount)
|
||||
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
|
||||
.ThenByDescending(item => item.AvgViewers ?? -1)
|
||||
.ThenByDescending(item => item.LastSubmittedAt)
|
||||
.ToArray();
|
||||
|
||||
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
|
||||
item.ResolvedChannel
|
||||
?? item.CandidateText
|
||||
?? item.StreamUrl
|
||||
?? "Name im Review festlegen";
|
||||
|
||||
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackerStatus
|
||||
?? "pending";
|
||||
}
|
||||
|
||||
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackingReviewStatus
|
||||
?? "clear";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
|
||||
{
|
||||
return item.CategoryGroupName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.GroupName ?? "Unbekannte Hauptkategorie";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryName))
|
||||
{
|
||||
return item.CategoryName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
|
||||
}
|
||||
|
||||
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
|
||||
new(
|
||||
flag.Key,
|
||||
flag.Label,
|
||||
flag.Severity,
|
||||
flag.Description,
|
||||
flag.RequiresManualReview,
|
||||
flag.BlocksApproval,
|
||||
flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
|
||||
AdminNominationRow row,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
trackingRules.ImportantMetrics
|
||||
.Concat(trackingRules.OptionalMetrics)
|
||||
.Where(metric => metric.Enabled && metric.ShowInReview)
|
||||
.Select(metric => new AdminTrackingMetricStateDto(
|
||||
metric.Key,
|
||||
metric.Label,
|
||||
metric.RequiredForAutoClassification,
|
||||
metric.SourceSupport,
|
||||
MetricPresent(metric, row),
|
||||
MetricValue(metric, row),
|
||||
metric.Description,
|
||||
metric.WindowKey,
|
||||
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||
.ToArray();
|
||||
|
||||
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
|
||||
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
|
||||
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
|
||||
_ => "manuell",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (metric.TopCount.HasValue)
|
||||
{
|
||||
parts.Add($"Top {metric.TopCount.Value}");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategorySharePercent.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategoryHours.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
|
||||
{
|
||||
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
|
||||
}
|
||||
|
||||
if (metric.IgnoredCategories.Length > 0)
|
||||
{
|
||||
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
|
||||
}
|
||||
|
||||
return parts.Count > 0
|
||||
? string.Join(" · ", parts)
|
||||
: "Top-Kategorien manuell pruefen";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategory")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminSeasonSubcategoryTemplates")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("CreateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||
.WithName("CreateAdminCandidate")
|
||||
@@ -56,11 +72,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("DeleteAdminResult")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/workflow-rules", GetWorkflowRules)
|
||||
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/workflow-rules", UpdateWorkflowRules)
|
||||
group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
|
||||
@@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
private sealed record CandidateRuleSnapshot(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string AcceptanceStatus);
|
||||
|
||||
private sealed record CandidateReadinessSnapshot(
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string AcceptanceStatus,
|
||||
string? ClipCompilationUrl);
|
||||
|
||||
private sealed record WinnerReadinessSnapshot(
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string? ClipCompilationUrl);
|
||||
|
||||
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||
{
|
||||
if (request.Year < 2020 || request.Year > 2100)
|
||||
@@ -145,13 +161,16 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
return WorkflowRuleSettings.Read(settings);
|
||||
return WorkflowRuleSettings.Read(season, settings);
|
||||
}
|
||||
|
||||
private static IResult CreateWorkflowRuleError(string message) =>
|
||||
@@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
int? streamerIdentityId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
@@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
|
||||
var rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
@@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new CandidateRuleSnapshot(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus))
|
||||
@@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
@@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
bool isCurrent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
if (season is null)
|
||||
{
|
||||
return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."];
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||
@@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return [];
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
|
||||
var categoryIds = await db.Categories
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
var categoriesWithCandidates = await db.Candidates
|
||||
var candidateSnapshots = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new CandidateReadinessSnapshot(
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus,
|
||||
item.ClipCompilationUrl))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var activeCandidates = candidateSnapshots
|
||||
.Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var categoriesWithCandidates = activeCandidates
|
||||
.Select(item => item.CategoryId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
.Count();
|
||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||
if (emptyCategories > 0)
|
||||
{
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten.");
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
var identityOverflow = activeCandidates
|
||||
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||
})
|
||||
.Where(item => item.Count > appearancesRule.Limit)
|
||||
.OrderByDescending(item => item.Count)
|
||||
.FirstOrDefault();
|
||||
if (identityOverflow is not null)
|
||||
{
|
||||
issues.Add(
|
||||
$"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWinnerReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now))
|
||||
{
|
||||
issues.Add("Die Award Show liegt noch nicht in der Vergangenheit.");
|
||||
}
|
||||
|
||||
var categoriesWithResults = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||
}
|
||||
|
||||
var resultSnapshots = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new WinnerReadinessSnapshot(
|
||||
item.CategoryId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.ClipCompilationUrl))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
||||
{
|
||||
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
||||
if (missingWinnerClipCount > 0)
|
||||
{
|
||||
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
||||
}
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||
{
|
||||
var winnerOverflow = resultSnapshots
|
||||
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||
})
|
||||
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
||||
.OrderByDescending(item => item.Count)
|
||||
.FirstOrDefault();
|
||||
if (winnerOverflow is not null)
|
||||
{
|
||||
issues.Add(
|
||||
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug)
|
||||
{
|
||||
if (streamerIdentityId.HasValue)
|
||||
{
|
||||
return $"identity:{streamerIdentityId.Value}";
|
||||
}
|
||||
|
||||
return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
}
|
||||
|
||||
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
||||
{
|
||||
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
||||
@@ -364,6 +484,21 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMin is < 0 or > 100000)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMax is < 0 or > 100000)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
||||
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
||||
@@ -56,11 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
var existingWinnerCount = existingWinnerIdentities.Count(item =>
|
||||
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
|
||||
||
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
|
||||
|
||||
if (existingWinnerCount >= winnerPlacementsRule.Limit)
|
||||
|
||||
@@ -41,6 +41,22 @@ public static class AdminSiteSettingsEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOptionalFeatureSettings")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/tracking-rules", GetTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules", UpdateTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/source", UpdateTrackingSource)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingSource")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingReviewNotes")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName,
|
||||
settings.HostTagline,
|
||||
settings.NewsletterUrl,
|
||||
settings.ShareXUrl,
|
||||
settings.ShareDiscordUrl,
|
||||
settings.PrivacyEmail,
|
||||
settings.PrivacyPolicyContent,
|
||||
settings.PrivacyPolicyUpdatedBy,
|
||||
@@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ShowactsUrl,
|
||||
settings.ShowactsContent,
|
||||
SeasonMappings.ReadSocialLinks(settings),
|
||||
SeasonMappings.ReadFaqItems(settings)));
|
||||
SeasonMappings.ReadFaqItems(settings),
|
||||
settings.ShowactFormSchemaJson ?? "[]"));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSiteSettings(
|
||||
@@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||
settings.HostTagline = request.HostTagline.Trim();
|
||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
||||
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||
@@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ShowactsContent = request.ShowactsContent.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
socialLinks = [];
|
||||
|
||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|
||||
@@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
normalizedUrls = new PublicSiteUrlSettings
|
||||
{
|
||||
NewsletterUrl = newsletterUrl,
|
||||
ShareXUrl = shareXUrl,
|
||||
ShareDiscordUrl = shareDiscordUrl,
|
||||
ImprintUrl = imprintUrl,
|
||||
ContactUrl = contactUrl,
|
||||
SponsorsUrl = sponsorsUrl,
|
||||
@@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
private sealed class PublicSiteUrlSettings
|
||||
{
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ShareXUrl { get; set; } = string.Empty;
|
||||
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||
public string ImprintUrl { get; set; } = string.Empty;
|
||||
public string ContactUrl { get; set; } = string.Empty;
|
||||
public string SponsorsUrl { get; set; } = string.Empty;
|
||||
@@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTrackingRules(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToTrackingRulesResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingRules(
|
||||
HttpContext context,
|
||||
UpdateTrackingRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var applyResult = ApplyTrackingRulesRequest(settings, request);
|
||||
if (applyResult is not null)
|
||||
{
|
||||
return applyResult;
|
||||
}
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Rules wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl,
|
||||
manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes,
|
||||
importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled),
|
||||
optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled),
|
||||
flagCount = after.Flags.Count(item => item.Enabled),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingSource(
|
||||
HttpContext context,
|
||||
UpdateTrackingSourceRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
var updatedRules = rules with
|
||||
{
|
||||
Source = new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? rules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview),
|
||||
};
|
||||
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules);
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-source.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Source wurde aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeBaseUrl = before.Source.BaseUrl,
|
||||
afterBaseUrl = after.Source.BaseUrl,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingReviewNotes(
|
||||
HttpContext context,
|
||||
UpdateTrackingReviewNotesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var beforeNotes = settings.TrackingReviewNotes ?? string.Empty;
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with
|
||||
{
|
||||
Source = rules.Source with
|
||||
{
|
||||
ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview,
|
||||
},
|
||||
});
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-review-notes.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Review Notes wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeLength = beforeNotes.Length,
|
||||
afterLength = settings.TrackingReviewNotes.Length,
|
||||
beforeShowInReview = before.Source.ShowManualReviewNotesInReview,
|
||||
afterShowInReview = after.Source.ShowManualReviewNotesInReview,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
||||
HttpContext context,
|
||||
UpdateOptionalFeatureSettingsRequest request,
|
||||
@@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
}
|
||||
|
||||
var before = ToOptionalFeatureSettingsResponse(settings);
|
||||
var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt);
|
||||
if (scheduleValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = scheduleValidationError });
|
||||
}
|
||||
|
||||
var disabledMessage = NormalizeOptionalFeatureText(
|
||||
request.ClipSubmissionDisabledMessage,
|
||||
FallbackClipSubmissionDisabledMessage,
|
||||
@@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
||||
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
||||
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
||||
settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt;
|
||||
settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt;
|
||||
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
||||
settings.SponsorsVisible = request.SponsorsVisible;
|
||||
|
||||
@@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints
|
||||
? FallbackClipSubmissionDisabledMessage
|
||||
: settings.ClipSubmissionDisabledMessage,
|
||||
settings.ShowactApplicationsEnabled,
|
||||
settings.ShowactApplicationStartsAt,
|
||||
settings.ShowactApplicationEndsAt,
|
||||
ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: settings.ShowactApplicationDisabledMessage,
|
||||
@@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
||||
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
||||
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
||||
AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt);
|
||||
AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt);
|
||||
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
||||
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
||||
return changes.ToArray();
|
||||
@@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||
usesDatabaseDemo,
|
||||
@@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
twitchSettings.ClientSecretSet,
|
||||
twitchSettings.RedirectUri,
|
||||
twitchSettings.Scope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||
@@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
||||
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
||||
var twitchScope = request.TwitchScope.Trim();
|
||||
if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." });
|
||||
}
|
||||
|
||||
var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours);
|
||||
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||
|
||||
@@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.TwitchClientSecret,
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope);
|
||||
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
|
||||
|
||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||
? newPassword
|
||||
@@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
"operational-settings.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
|
||||
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
settings.DemoLoginEnabled,
|
||||
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
||||
settings.SessionIdleTimeoutHours,
|
||||
settings.MaintenanceModeEnabled,
|
||||
changes,
|
||||
},
|
||||
@@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||
@@ -576,6 +781,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
||||
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
||||
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
||||
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
|
||||
|
||||
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
||||
{
|
||||
@@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
@@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints
|
||||
string RedirectUri,
|
||||
string Scope,
|
||||
bool Configured);
|
||||
|
||||
private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request)
|
||||
{
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var currentRules = TrackingRulesSettings.Read(settings);
|
||||
var configuration = new TrackingRulesConfiguration(
|
||||
new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? currentRules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview),
|
||||
(request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray());
|
||||
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration);
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings)
|
||||
{
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
return new AdminTrackingRulesResponse(
|
||||
new AdminTrackingSourceDto(
|
||||
rules.Source.ProviderKey,
|
||||
"TwitchTracker Basic API",
|
||||
TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl),
|
||||
rules.Source.NotesSummary,
|
||||
rules.Source.ShowManualReviewNotesInReview),
|
||||
rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(),
|
||||
settings.TrackingReviewNotes ?? string.Empty);
|
||||
}
|
||||
|
||||
private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
rule.WindowKey,
|
||||
rule.AutoSupportedWindowKeys,
|
||||
rule.ProviderFieldKey,
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
rule.IgnoredCategories,
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d),
|
||||
(rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(),
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
(rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage)
|
||||
{
|
||||
normalizedValue = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
var trimmed = (rawValue ?? string.Empty).Trim();
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,33 +8,41 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
|
||||
private static async Task<IResult> GetWorkflowRules(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(season, settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWorkflowRules(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateWorkflowRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = WorkflowRuleSettings.Read(settings);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var before = WorkflowRuleSettings.Read(season, settings);
|
||||
var mergedRules = WorkflowRuleSettings.Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
@@ -51,8 +59,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(settings);
|
||||
season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(season, settings);
|
||||
var changes = after
|
||||
.Select(rule =>
|
||||
{
|
||||
@@ -72,10 +80,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"workflow-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Workflow-Regeln wurden aktualisiert.",
|
||||
new { changes },
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.",
|
||||
new { seasonId = season.Id, season.Year, changes },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
|
||||
@@ -12,6 +12,7 @@ public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> DemoLogin(
|
||||
HttpContext context,
|
||||
IHostEnvironment environment,
|
||||
AwardsDbContext db,
|
||||
IConfiguration configuration,
|
||||
DemoLoginRequest request,
|
||||
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
|
||||
var password = request.Password ?? string.Empty;
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var databaseDemoConfigured = settings is not null
|
||||
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
|
||||
&& settings.DemoLoginManagedByDatabase;
|
||||
|
||||
string twitchUserId;
|
||||
string displayName;
|
||||
bool credentialsMatch;
|
||||
var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (databaseDemoConfigured && settings is not null)
|
||||
{
|
||||
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
|
||||
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||
displayName = settings.DemoLoginDisplayName.Trim();
|
||||
|
||||
if (!credentialsMatch
|
||||
&& environment.IsDevelopment()
|
||||
&& IsDemoLoginEnabled(configuration)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var configuredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuredLogin)
|
||||
|| string.IsNullOrWhiteSpace(configuredPassword)
|
||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(displayName))
|
||||
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Demo login is not fully configured." },
|
||||
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
|
||||
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
configuredLogin,
|
||||
configuredEmail,
|
||||
twitchUserId,
|
||||
displayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
|
||||
twitchUserId = twitchUserId.Trim();
|
||||
displayName = displayName.Trim();
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
|
||||
if (!credentialsMatch)
|
||||
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
|
||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||
@@ -36,6 +36,7 @@ public static partial class AuthEndpoints
|
||||
|
||||
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
UserSession session,
|
||||
bool mustChangePassword = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
|
||||
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||
var sessionRole = teamMember?.Role ?? session.Role;
|
||||
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
|
||||
return new(
|
||||
session.SessionToken,
|
||||
session.TwitchUserId,
|
||||
teamMember?.DisplayName ?? session.DisplayName,
|
||||
AdminRoles.Normalize(sessionRole),
|
||||
permissionKeys,
|
||||
sessionIdleTimeoutHours,
|
||||
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||
teamMember?.Login,
|
||||
teamMember?.BoundTwitchUserId,
|
||||
|
||||
@@ -44,7 +44,7 @@ public static partial class AuthEndpoints
|
||||
context.RequestAborted);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(
|
||||
@@ -92,7 +92,7 @@ public static partial class AuthEndpoints
|
||||
session.Role = AdminRoles.Normalize(member.Role);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static string BuildTeamSessionId(string login) =>
|
||||
|
||||
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||
false,
|
||||
false,
|
||||
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
|
||||
currentSessionUsesBoundTwitch,
|
||||
currentSessionUsesBoundTwitch
|
||||
? null
|
||||
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
@@ -8,6 +11,8 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
|
||||
|
||||
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
@@ -45,13 +50,19 @@ public static partial class PublicEndpoints
|
||||
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private static async Task<IResult> CreateShowactApplication(
|
||||
HttpContext context,
|
||||
CreateShowactApplicationRequest request,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (settings is null || !settings.ShowactApplicationsEnabled)
|
||||
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
@@ -67,6 +78,72 @@ public static partial class PublicEndpoints
|
||||
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
||||
var hasDynamicForm = schema.Count > 0;
|
||||
|
||||
if (hasDynamicForm)
|
||||
{
|
||||
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
|
||||
{
|
||||
// Legacy fixed-fields path
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
@@ -85,6 +162,11 @@ public static partial class PublicEndpoints
|
||||
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." });
|
||||
@@ -117,6 +199,17 @@ public static partial class PublicEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ShowactFieldDefinition
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||
[JsonPropertyName("required")] public bool Required { get; set; }
|
||||
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
|
||||
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
|
||||
}
|
||||
|
||||
private static string NormalizePublicText(string? value, int maxLength)
|
||||
{
|
||||
@@ -128,4 +221,167 @@ public static partial class PublicEndpoints
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||
|
||||
private static bool IsBlankOrValidEmail(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
|
||||
|
||||
private static bool IsBlankOrValidJsonObject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(value);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Object;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateShowactResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses)
|
||||
{
|
||||
foreach (var field in schema)
|
||||
{
|
||||
var value = responses.TryGetValue(field.Id, out var rawValue)
|
||||
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
|
||||
: string.Empty;
|
||||
responses[field.Id] = value;
|
||||
|
||||
if (field.Required && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
|
||||
? $"Bitte bestaetige: {field.Label}"
|
||||
: $"{field.Label} ist erforderlich.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
|
||||
{
|
||||
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
|
||||
{
|
||||
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
|
||||
{
|
||||
if (field.MaxLength > 0)
|
||||
{
|
||||
return Math.Min(field.MaxLength, 2000);
|
||||
}
|
||||
|
||||
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
|
||||
}
|
||||
|
||||
private static void MergeLegacyShowactFieldsIntoResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses,
|
||||
CreateShowactApplicationRequest request)
|
||||
{
|
||||
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
|
||||
MergeResponseValue(artistField, request.ArtistName, responses, 120);
|
||||
|
||||
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
|
||||
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
|
||||
|
||||
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
|
||||
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
|
||||
|
||||
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
|
||||
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
|
||||
|
||||
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
|
||||
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
|
||||
|
||||
var performanceField = schema.FirstOrDefault(field =>
|
||||
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|
||||
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
|
||||
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
|
||||
|
||||
var descriptionField = schema.FirstOrDefault(field =>
|
||||
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
|
||||
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|
||||
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
|
||||
MergeResponseValue(descriptionField, request.Description, responses, 1000);
|
||||
|
||||
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
|
||||
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
|
||||
}
|
||||
|
||||
private static void MergeResponseValue(
|
||||
ShowactFieldDefinition? field,
|
||||
string? requestValue,
|
||||
IDictionary<string, string> responses,
|
||||
int maxLength)
|
||||
{
|
||||
if (field is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = NormalizePublicText(requestValue, maxLength);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
responses[field.Id] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Id.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Label.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,14 @@ public static partial class PublicEndpoints
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
IRiskRuleService riskRuleService,
|
||||
NominationEnrichmentService nominationEnrichmentService)
|
||||
{
|
||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
||||
|
||||
if (submittedNominations.Length is 0 or > 3)
|
||||
if (submittedNominations.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
|
||||
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
||||
@@ -35,7 +36,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var distinctStreamUrls = submittedNominations
|
||||
.Select(item => item.StreamUrl)
|
||||
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
@@ -65,16 +66,37 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
||||
}
|
||||
|
||||
var category = await db.Categories
|
||||
.Include(item => item.Season)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||
|
||||
if (category is null)
|
||||
if (season is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||
return Results.BadRequest(new { message = "The selected season does not exist." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
|
||||
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted);
|
||||
if (string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var groupCategories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories);
|
||||
if (submittedNominations.Length > maxNomineesPerUser)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||
if (nominationSeasonResolution.Result is not null)
|
||||
{
|
||||
return nominationSeasonResolution.Result;
|
||||
@@ -89,15 +111,16 @@ public static partial class PublicEndpoints
|
||||
var submitterId = submitterIdResult.SubmitterId!;
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
item.SeasonId == season.Id
|
||||
&& item.CategoryGroupName == categoryGroupName
|
||||
&& item.SubmittedByTwitchId == submitterId
|
||||
&& item.Status == "pending");
|
||||
|
||||
var records = submittedNominations.Select(nomination => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
SeasonId = season.Id,
|
||||
CategoryId = null,
|
||||
CategoryGroupName = categoryGroupName,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||
@@ -106,6 +129,11 @@ public static partial class PublicEndpoints
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}).ToArray();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||
@@ -126,21 +154,21 @@ public static partial class PublicEndpoints
|
||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
resubmittedNominationRule.Severity,
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
||||
requestMetadata,
|
||||
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
@@ -152,7 +180,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
||||
return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 });
|
||||
}
|
||||
|
||||
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||
@@ -191,4 +219,44 @@ public static partial class PublicEndpoints
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeNominationUrlForCompare(string value) =>
|
||||
value.Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
|
||||
{
|
||||
var configuredLimit = groupCategories
|
||||
.Select(item => item.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return Math.Clamp(configuredLimit, 1, 10);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveCategoryGroupNameAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
CreateNominationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var categoryGroupName = request.CategoryGroupName?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!request.CategoryId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,18 @@ public static partial class PublicEndpoints
|
||||
{
|
||||
return Results.Problem("Site settings are missing.");
|
||||
}
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
|
||||
|
||||
var canExposeCurrentSeasonWinners = CanExposeCurrentSeasonWinners(season.CurrentPhase);
|
||||
|
||||
var winnerPreviewRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Season)
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result =>
|
||||
result.Season.Year < season.Year
|
||||
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||
.OrderByDescending(result => result.Season.Year)
|
||||
.ThenBy(result => result.CategoryName)
|
||||
.Take(8)
|
||||
@@ -65,7 +71,9 @@ public static partial class PublicEndpoints
|
||||
|
||||
var archiveYearRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result =>
|
||||
result.Season.Year < season.Year
|
||||
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||
.GroupBy(result => result.Season.Year)
|
||||
.Select(group => new
|
||||
{
|
||||
@@ -83,6 +91,33 @@ public static partial class PublicEndpoints
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var featuredCategories = publicCategories
|
||||
.GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group
|
||||
.OrderBy(category => category.SortOrder)
|
||||
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var first = ordered[0];
|
||||
var maxNomineesPerUser = ordered
|
||||
.Select(category => category.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return new FeaturedCategoryDto(
|
||||
first.Id,
|
||||
first.GroupName,
|
||||
first.GroupName,
|
||||
first.Description,
|
||||
maxNomineesPerUser);
|
||||
})
|
||||
.OrderBy(category => publicCategories
|
||||
.Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase))
|
||||
.Min(item => item.SortOrder))
|
||||
.ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var response = new OverviewResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
@@ -100,20 +135,15 @@ public static partial class PublicEndpoints
|
||||
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
||||
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||
},
|
||||
publicCategories
|
||||
.Select(category => new FeaturedCategoryDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser))
|
||||
.ToArray(),
|
||||
featuredCategories,
|
||||
winnerPreviewItems,
|
||||
archiveYears,
|
||||
new PublicSiteContentDto(
|
||||
siteSettings.HostDisplayName,
|
||||
siteSettings.HostTagline,
|
||||
siteSettings.NewsletterUrl,
|
||||
siteSettings.ShareXUrl,
|
||||
siteSettings.ShareDiscordUrl,
|
||||
siteSettings.PrivacyEmail,
|
||||
siteSettings.PrivacyPolicyContent,
|
||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||
@@ -124,11 +154,14 @@ public static partial class PublicEndpoints
|
||||
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||
: siteSettings.ClipSubmissionDisabledMessage,
|
||||
siteSettings.ShowactApplicationsEnabled,
|
||||
showactApplicationsOpenNow,
|
||||
siteSettings.ShowactApplicationStartsAt,
|
||||
siteSettings.ShowactApplicationEndsAt,
|
||||
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: siteSettings.ShowactApplicationDisabledMessage,
|
||||
siteSettings.SponsorsVisible),
|
||||
siteSettings.SponsorsVisible,
|
||||
siteSettings.ShowactFormSchemaJson ?? "[]"),
|
||||
SeasonMappings.ReadFaqItems(siteSettings));
|
||||
|
||||
return Results.Ok(response);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Common;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||
@@ -54,6 +57,8 @@ public static partial class PublicEndpoints
|
||||
category.GroupName,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
category.Candidates
|
||||
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(candidate =>
|
||||
|
||||
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
|
||||
|
||||
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||
{
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
if (!usesDatabaseDemo)
|
||||
{
|
||||
return IsDemoLoginEnabled(configuration);
|
||||
|
||||
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.CategoryGroupName,
|
||||
item.Status,
|
||||
Nominee = item.CandidateId != null
|
||||
? item.Candidate!.DisplayName
|
||||
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
|
||||
var groupedNominations = nominations
|
||||
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
|
||||
.Select(group => new UserNominationStateDto(
|
||||
group.Key,
|
||||
group.Key.CategoryId,
|
||||
group.Key.CategoryGroupName,
|
||||
group.Select(item => item.Nominee!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()))
|
||||
|
||||
@@ -26,6 +26,11 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("TwitchTracker", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(4);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
|
||||
});
|
||||
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
||||
|
||||
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
||||
@@ -88,6 +93,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
||||
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
||||
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
||||
services.AddScoped<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
|
||||
services.AddScoped<NominationTrackingReviewService>();
|
||||
services.AddScoped<NominationEnrichmentService>();
|
||||
services.AddScoped<AdminSessionFilter>();
|
||||
|
||||
return services;
|
||||
|
||||
+1768
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShareUrls : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShareDiscordUrl",
|
||||
table: "SiteSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShareXUrl",
|
||||
table: "SiteSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "ShareDiscordUrl", "ShareXUrl" },
|
||||
values: new object[] { "", "" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShareDiscordUrl",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShareXUrl",
|
||||
table: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShowactDynamicForm : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShowactFormSchemaJson",
|
||||
table: "SiteSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FieldResponsesJson",
|
||||
table: "ShowactApplications",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "ShowactFormSchemaJson",
|
||||
value: "[]");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowactFormSchemaJson",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FieldResponsesJson",
|
||||
table: "ShowactApplications");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCategoryViewerRanges : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ViewerRangeMax",
|
||||
table: "Categories",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ViewerRangeMin",
|
||||
table: "Categories",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 5,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 6,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 7,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 8,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 9,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Categories",
|
||||
keyColumn: "Id",
|
||||
keyValue: 10,
|
||||
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||
values: new object[] { null, null });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ViewerRangeMax",
|
||||
table: "Categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ViewerRangeMin",
|
||||
table: "Categories");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1789
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSessionIdleTimeoutSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SessionIdleTimeoutHours",
|
||||
table: "SiteSettings",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 3);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "SessionIdleTimeoutHours",
|
||||
value: 3);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SessionIdleTimeoutHours",
|
||||
table: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1799
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeasonSubcategoryTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SubcategoryTemplatesJson",
|
||||
table: "Seasons",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "SubcategoryTemplatesJson",
|
||||
value: "[]");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubcategoryTemplatesJson",
|
||||
table: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1936
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNominationGroupTrackerIdentity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "CategoryId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "integer");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgViewers",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CategoryGroupName",
|
||||
table: "Nominations",
|
||||
type: "character varying(80)",
|
||||
maxLength: 80,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResolvedChannel",
|
||||
table: "Nominations",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResolvedPlatform",
|
||||
table: "Nominations",
|
||||
type: "character varying(40)",
|
||||
maxLength: 40,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SuggestedCategoryId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "TrackerCheckedAt",
|
||||
table: "Nominations",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TrackerStatus",
|
||||
table: "Nominations",
|
||||
type: "character varying(40)",
|
||||
maxLength: 40,
|
||||
nullable: false,
|
||||
defaultValue: "pending");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "NominationTally",
|
||||
table: "Candidates",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Candidates",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StreamerIdentities",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Platform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
Login = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
NormalizedKey = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ProfileUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
LastResolvedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StreamerIdentities", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 5,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 6,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 7,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 8,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 9,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 10,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 11,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 12,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Candidates",
|
||||
keyColumn: "Id",
|
||||
keyValue: 13,
|
||||
column: "StreamerIdentityId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Nominations",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" },
|
||||
values: new object[] { null, "", null, null, null, null, null, "pending" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Nominations",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" },
|
||||
values: new object[] { null, "", null, null, null, null, null, "pending" });
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "Nominations" n
|
||||
SET "CategoryGroupName" = c."GroupName"
|
||||
FROM "Categories" c
|
||||
WHERE n."CategoryId" = c."Id"
|
||||
AND COALESCE(n."CategoryGroupName", '') = '';
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_CategoryGroupName_Status",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "CategoryGroupName", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "StreamerIdentityId", "CategoryGroupName" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_StreamerIdentityId",
|
||||
table: "Nominations",
|
||||
column: "StreamerIdentityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SuggestedCategoryId",
|
||||
table: "Nominations",
|
||||
column: "SuggestedCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Candidates_StreamerIdentityId",
|
||||
table: "Candidates",
|
||||
column: "StreamerIdentityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StreamerIdentities_NormalizedKey",
|
||||
table: "StreamerIdentities",
|
||||
column: "NormalizedKey",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Candidates_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Candidates",
|
||||
column: "StreamerIdentityId",
|
||||
principalTable: "StreamerIdentities",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations",
|
||||
column: "CategoryId",
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_Categories_SuggestedCategoryId",
|
||||
table: "Nominations",
|
||||
column: "SuggestedCategoryId",
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Nominations",
|
||||
column: "StreamerIdentityId",
|
||||
principalTable: "StreamerIdentities",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Candidates_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_Categories_SuggestedCategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Nominations_StreamerIdentities_StreamerIdentityId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StreamerIdentities");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SeasonId_CategoryGroupName_Status",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_StreamerIdentityId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SuggestedCategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Candidates_StreamerIdentityId",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgViewers",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CategoryGroupName",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResolvedChannel",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResolvedPlatform",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SuggestedCategoryId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TrackerCheckedAt",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TrackerStatus",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NominationTally",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StreamerIdentityId",
|
||||
table: "Candidates");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "CategoryId",
|
||||
table: "Nominations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "integer",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
table: "Nominations",
|
||||
column: "CategoryId",
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1942
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShowactApplicationSchedule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "ShowactApplicationEndsAt",
|
||||
table: "SiteSettings",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "ShowactApplicationStartsAt",
|
||||
table: "SiteSettings",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "ShowactApplicationEndsAt", "ShowactApplicationStartsAt" },
|
||||
values: new object[] { null, null });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowactApplicationEndsAt",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowactApplicationStartsAt",
|
||||
table: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeasonWorkflowRulesJson : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "WorkflowRulesJson",
|
||||
table: "Seasons",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "Seasons"
|
||||
SET "WorkflowRulesJson" = COALESCE(NULLIF((SELECT "WorkflowRulesJson" FROM "SiteSettings" WHERE "Id" = 1), ''), '[]')
|
||||
WHERE COALESCE("WorkflowRulesJson", '') = '';
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WorkflowRulesJson",
|
||||
table: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -10,5 +10,6 @@ public interface IUserSessionService
|
||||
Task<UserSession> CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default);
|
||||
Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default);
|
||||
Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||
Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default);
|
||||
Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Backend.Services;
|
||||
|
||||
public interface IViewerStatsProvider
|
||||
{
|
||||
Task<ViewerStatsSnapshot?> GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ViewerStatsSnapshot(
|
||||
int AverageViewers,
|
||||
int HoursStreamed,
|
||||
int HoursWatched,
|
||||
int PeakViewers,
|
||||
int FollowersGained,
|
||||
string WindowKey);
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed partial class NominationEnrichmentService(
|
||||
AwardsDbContext db,
|
||||
IViewerStatsProvider viewerStatsProvider,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
public async Task EnrichAsync(Nomination nomination, IReadOnlyCollection<Category> groupCategories, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveStreamIdentity(nomination.StreamUrl, out var identity))
|
||||
{
|
||||
nomination.TrackerStatus = "unresolved";
|
||||
nomination.TrackerCheckedAt = DateTimeOffset.UtcNow;
|
||||
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var streamerIdentity = await db.StreamerIdentities
|
||||
.FirstOrDefaultAsync(item => item.NormalizedKey == identity.NormalizedKey, cancellationToken);
|
||||
|
||||
if (streamerIdentity is null)
|
||||
{
|
||||
streamerIdentity = new StreamerIdentity
|
||||
{
|
||||
Platform = identity.Platform,
|
||||
Login = identity.Login,
|
||||
NormalizedKey = identity.NormalizedKey,
|
||||
DisplayName = identity.DisplayName,
|
||||
ProfileUrl = identity.ProfileUrl,
|
||||
};
|
||||
db.StreamerIdentities.Add(streamerIdentity);
|
||||
}
|
||||
else
|
||||
{
|
||||
streamerIdentity.Platform = identity.Platform;
|
||||
streamerIdentity.Login = identity.Login;
|
||||
streamerIdentity.DisplayName = string.IsNullOrWhiteSpace(streamerIdentity.DisplayName)
|
||||
? identity.DisplayName
|
||||
: streamerIdentity.DisplayName;
|
||||
streamerIdentity.ProfileUrl ??= identity.ProfileUrl;
|
||||
}
|
||||
|
||||
streamerIdentity.LastResolvedAt = DateTimeOffset.UtcNow;
|
||||
nomination.StreamerIdentity = streamerIdentity;
|
||||
nomination.ResolvedChannel = identity.Login;
|
||||
nomination.ResolvedPlatform = identity.Platform;
|
||||
nomination.TrackerCheckedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (!identity.SupportsViewerStats)
|
||||
{
|
||||
nomination.TrackerStatus = "unsupported_platform";
|
||||
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = await viewerStatsProvider.GetChannelSummaryAsync(identity.Login, cancellationToken);
|
||||
nomination.AvgViewers = summary?.AverageViewers;
|
||||
nomination.HoursStreamed = summary?.HoursStreamed;
|
||||
nomination.HoursWatched = summary?.HoursWatched;
|
||||
nomination.PeakViewers = summary?.PeakViewers;
|
||||
nomination.FollowersGained = summary?.FollowersGained;
|
||||
nomination.SuggestedCategoryId = nomination.AvgViewers.HasValue
|
||||
? ResolveSuggestedCategoryId(groupCategories, nomination.AvgViewers.Value)
|
||||
: null;
|
||||
nomination.TrackerStatus = summary is not null ? "resolved" : "no_data";
|
||||
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
|
||||
}
|
||||
|
||||
public static bool TryResolveStreamIdentity(string? streamUrl, out ResolvedStreamerIdentity identity)
|
||||
{
|
||||
identity = default;
|
||||
if (string.IsNullOrWhiteSpace(streamUrl) || !Uri.TryCreate(streamUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var host = uri.Host.Replace("www.", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
|
||||
var pathParts = uri.AbsolutePath
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(part => part.TrimStart('@'))
|
||||
.Where(part => !IgnoredPathParts.Contains(part, StringComparer.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var login = pathParts.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
login = pathParts.FirstOrDefault() ?? uri.Host;
|
||||
}
|
||||
|
||||
var platform = ResolvePlatform(host);
|
||||
login = SanitizeLogin(login);
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var profileUrl = BuildProfileUrl(platform, login, uri);
|
||||
identity = new ResolvedStreamerIdentity(
|
||||
platform,
|
||||
login,
|
||||
$"{platform.ToLowerInvariant()}:{login.ToLowerInvariant()}",
|
||||
login,
|
||||
profileUrl,
|
||||
string.Equals(platform, "Twitch", StringComparison.OrdinalIgnoreCase));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int? ResolveSuggestedCategoryId(IEnumerable<Category> groupCategories, int averageViewers)
|
||||
{
|
||||
var orderedCategories = groupCategories
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToArray();
|
||||
|
||||
var rangedCategories = orderedCategories
|
||||
.Where(category => category.ViewerRangeMin.HasValue || category.ViewerRangeMax.HasValue)
|
||||
.ToArray();
|
||||
|
||||
if (rangedCategories.Length > 0)
|
||||
{
|
||||
return rangedCategories
|
||||
.FirstOrDefault(category =>
|
||||
(!category.ViewerRangeMin.HasValue || averageViewers >= category.ViewerRangeMin.Value)
|
||||
&& (!category.ViewerRangeMax.HasValue || averageViewers <= category.ViewerRangeMax.Value))
|
||||
?.Id;
|
||||
}
|
||||
|
||||
return orderedCategories
|
||||
.FirstOrDefault()
|
||||
?.Id;
|
||||
}
|
||||
|
||||
private static string ResolvePlatform(string host)
|
||||
{
|
||||
if (host.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase)) return "Twitch";
|
||||
if (host.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) || host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase)) return "YouTube";
|
||||
if (host.Contains("kick.com", StringComparison.OrdinalIgnoreCase)) return "Kick";
|
||||
|
||||
var firstPart = host.Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
return string.IsNullOrWhiteSpace(firstPart)
|
||||
? "Website"
|
||||
: $"{char.ToUpperInvariant(firstPart[0])}{firstPart[1..]}";
|
||||
}
|
||||
|
||||
private static string BuildProfileUrl(string platform, string login, Uri originalUrl) =>
|
||||
platform.ToLowerInvariant() switch
|
||||
{
|
||||
"twitch" => $"https://twitch.tv/{login}",
|
||||
"youtube" => $"https://youtube.com/{login}",
|
||||
"kick" => $"https://kick.com/{login}",
|
||||
_ => originalUrl.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped),
|
||||
};
|
||||
|
||||
private static string SanitizeLogin(string value) =>
|
||||
LoginRegex().Replace(value.Trim().TrimStart('@'), string.Empty);
|
||||
|
||||
private static readonly string[] IgnoredPathParts = ["c", "channel", "user", "live", "videos", "video", "clip", "clips", "directory"];
|
||||
|
||||
[GeneratedRegex("[^a-zA-Z0-9._-]", RegexOptions.Compiled)]
|
||||
private static partial Regex LoginRegex();
|
||||
}
|
||||
|
||||
public readonly record struct ResolvedStreamerIdentity(
|
||||
string Platform,
|
||||
string Login,
|
||||
string NormalizedKey,
|
||||
string DisplayName,
|
||||
string ProfileUrl,
|
||||
bool SupportsViewerStats);
|
||||
@@ -0,0 +1,225 @@
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed class NominationTrackingReviewService(AwardsDbContext db)
|
||||
{
|
||||
public async Task ReevaluateAsync(Nomination nomination, bool resetManualResolution, CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
ApplyEvaluation(nomination, TrackingRulesSettings.Read(settings), resetManualResolution);
|
||||
}
|
||||
|
||||
public async Task ReevaluateAllAsync(bool resetManualResolution, CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
await ReevaluateAllAsync(rules, resetManualResolution, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReevaluateAllAsync(
|
||||
TrackingRulesConfiguration rules,
|
||||
bool resetManualResolution,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nominations = await db.Nominations.ToArrayAsync(cancellationToken);
|
||||
foreach (var nomination in nominations)
|
||||
{
|
||||
ApplyEvaluation(nomination, rules, resetManualResolution);
|
||||
}
|
||||
}
|
||||
|
||||
public NominationTrackingEvaluation Evaluate(Nomination nomination, TrackingRulesConfiguration rules)
|
||||
{
|
||||
var requiredMetrics = rules.ImportantMetrics
|
||||
.Where(item => item.Enabled && item.RequiredForAutoClassification)
|
||||
.ToArray();
|
||||
var missingRequiredMetrics = requiredMetrics
|
||||
.Where(metric => !IsMetricPresent(metric, nomination))
|
||||
.ToArray();
|
||||
var unsupportedAutomaticWindows = rules.ImportantMetrics
|
||||
.Concat(rules.OptionalMetrics)
|
||||
.Where(item => item.Enabled && item.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(item))
|
||||
.ToArray();
|
||||
|
||||
var triggeredFlags = new List<TrackingFlagHit>();
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagTrackerUnresolved, nomination.TrackerStatus == "unresolved");
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedPlatform, nomination.TrackerStatus == "unsupported_platform");
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagNoTrackerData, nomination.TrackerStatus == "no_data");
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagMissingRequiredMetric, missingRequiredMetrics.Length > 0);
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedMetricWindow, unsupportedAutomaticWindows.Length > 0);
|
||||
|
||||
var needsManualReview = triggeredFlags.Any(flag => flag.RequiresManualReview);
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagManualReviewRequired, needsManualReview);
|
||||
|
||||
return new NominationTrackingEvaluation(
|
||||
missingRequiredMetrics.Select(item => item.Key).ToArray(),
|
||||
triggeredFlags.ToArray(),
|
||||
needsManualReview || triggeredFlags.Any(flag => flag.Key == TrackingRulesSettings.FlagManualReviewRequired),
|
||||
triggeredFlags.Any(flag => flag.BlocksApproval),
|
||||
triggeredFlags.Any(flag => flag.AdminNoteRequiredOnOverride));
|
||||
}
|
||||
|
||||
public TrackingMetricState[] BuildMetricStates(Nomination nomination, TrackingRulesConfiguration rules) =>
|
||||
rules.ImportantMetrics
|
||||
.Concat(rules.OptionalMetrics)
|
||||
.Where(item => item.Enabled && item.ShowInReview)
|
||||
.Select(metric => new TrackingMetricState(
|
||||
metric.Key,
|
||||
metric.Label,
|
||||
metric.RequiredForAutoClassification,
|
||||
metric.SourceSupport,
|
||||
IsMetricPresent(metric, nomination),
|
||||
ResolveMetricValue(metric, nomination),
|
||||
metric.Description,
|
||||
metric.WindowKey,
|
||||
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||
.ToArray();
|
||||
|
||||
private void ApplyEvaluation(Nomination nomination, TrackingRulesConfiguration rules, bool resetManualResolution)
|
||||
{
|
||||
var evaluation = Evaluate(nomination, rules);
|
||||
nomination.TrackingFlagsJson = TrackingRulesSettings.SerializeFlagHits(evaluation.Flags);
|
||||
|
||||
if (resetManualResolution || nomination.TrackingReviewStatus is not ("reviewed" or "overridden"))
|
||||
{
|
||||
nomination.TrackingReviewStatus = evaluation.RequiresManualReview ? "flagged" : "clear";
|
||||
if (resetManualResolution)
|
||||
{
|
||||
nomination.TrackingReviewNote = null;
|
||||
nomination.TrackingReviewedByTwitchId = null;
|
||||
nomination.TrackingReviewedAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsMetricPresent(TrackingMetricRuleSetting metric, Nomination nomination)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => nomination.AvgViewers.HasValue,
|
||||
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(nomination.TrackerStatus),
|
||||
TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt.HasValue,
|
||||
TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed.HasValue,
|
||||
TrackingRulesSettings.HoursWatched => nomination.HoursWatched.HasValue,
|
||||
TrackingRulesSettings.PeakViewers => nomination.PeakViewers.HasValue,
|
||||
TrackingRulesSettings.FollowersGained => nomination.FollowersGained.HasValue,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveMetricValue(TrackingMetricRuleSetting metric, Nomination nomination)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => nomination.AvgViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(nomination.TrackerStatus) ? "offen" : nomination.TrackerStatus,
|
||||
TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||
TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.HoursWatched => nomination.HoursWatched?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.PeakViewers => nomination.PeakViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.FollowersGained => nomination.FollowersGained?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
|
||||
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
|
||||
_ => "manuell",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (metric.TopCount.HasValue)
|
||||
{
|
||||
parts.Add($"Top {metric.TopCount.Value}");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategorySharePercent.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategoryHours.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
|
||||
{
|
||||
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
|
||||
}
|
||||
|
||||
if (metric.IgnoredCategories.Length > 0)
|
||||
{
|
||||
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
|
||||
}
|
||||
|
||||
return parts.Count > 0
|
||||
? string.Join(" · ", parts)
|
||||
: "Top-Kategorien manuell pruefen";
|
||||
}
|
||||
|
||||
private static void AddFlagIf(
|
||||
ICollection<TrackingFlagHit> target,
|
||||
IEnumerable<TrackingFlagRuleSetting> availableFlags,
|
||||
string key,
|
||||
bool shouldAdd)
|
||||
{
|
||||
if (!shouldAdd)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rule = availableFlags.FirstOrDefault(item => item.Key == key);
|
||||
if (rule is null || !rule.Enabled || !rule.AutoTriggerEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
target.Add(new TrackingFlagHit(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NominationTrackingEvaluation(
|
||||
string[] MissingRequiredMetricKeys,
|
||||
TrackingFlagHit[] Flags,
|
||||
bool RequiresManualReview,
|
||||
bool HasBlockingFlag,
|
||||
bool RequiresOverrideNote);
|
||||
|
||||
public sealed record TrackingMetricState(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Required,
|
||||
string SourceSupport,
|
||||
bool Present,
|
||||
string Value,
|
||||
string Description,
|
||||
string WindowKey,
|
||||
string WindowLabel,
|
||||
bool AutoWindowSupported);
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Backend.Contracts;
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed record SeasonSubcategoryTemplateSetting(
|
||||
string Name,
|
||||
string Slug,
|
||||
int SortOrder,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public static class SeasonSubcategoryTemplateSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly SeasonSubcategoryTemplateSetting[] DefaultTemplates =
|
||||
[
|
||||
new("Hidden Star", "hidden-star", 1, 1, 20),
|
||||
new("Rising Star", "rising-star", 2, 21, 60),
|
||||
new("Shining Star", "shining-star", 3, 61, null),
|
||||
];
|
||||
|
||||
public static SeasonSubcategoryTemplateSetting[] Read(Season season, IEnumerable<Category>? fallbackCategories = null)
|
||||
{
|
||||
var stored = OnlyViewerTemplates(Parse(season.SubcategoryTemplatesJson));
|
||||
if (stored.Length > 0)
|
||||
{
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (fallbackCategories is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var fallback = fallbackCategories
|
||||
.GroupBy(category => new { category.Name, category.ViewerRangeMin, category.ViewerRangeMax })
|
||||
.Where(group => group.Key.ViewerRangeMin is not null || group.Key.ViewerRangeMax is not null)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.OrderBy(item => item.SortOrder).First();
|
||||
return Normalize(new SeasonSubcategoryTemplateSetting(
|
||||
first.Name,
|
||||
ExtractTemplateSlug(first.Slug, first.GroupName),
|
||||
first.SortOrder,
|
||||
first.ViewerRangeMin,
|
||||
first.ViewerRangeMax));
|
||||
})
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
|
||||
return fallback.Length > 0 ? fallback : DefaultTemplates;
|
||||
}
|
||||
|
||||
public static string Serialize(IEnumerable<SeasonSubcategoryTemplateSetting> templates) =>
|
||||
JsonSerializer.Serialize(
|
||||
templates.Select(Normalize)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase),
|
||||
JsonOptions);
|
||||
|
||||
public static SeasonSubcategoryTemplateSetting[] Normalize(IEnumerable<AdminSubcategoryTemplateDto>? templates) =>
|
||||
(templates ?? [])
|
||||
.Select(template => Normalize(new SeasonSubcategoryTemplateSetting(
|
||||
template.Name,
|
||||
template.Slug,
|
||||
template.SortOrder,
|
||||
template.ViewerRangeMin,
|
||||
template.ViewerRangeMax)))
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
|
||||
public static AdminSubcategoryTemplateDto[] ToDtos(IEnumerable<SeasonSubcategoryTemplateSetting> templates) =>
|
||||
templates
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => new AdminSubcategoryTemplateDto(
|
||||
item.Name,
|
||||
item.Slug,
|
||||
item.SortOrder,
|
||||
item.ViewerRangeMin,
|
||||
item.ViewerRangeMax))
|
||||
.ToArray();
|
||||
|
||||
public static bool MatchesTemplate(Category category, IEnumerable<SeasonSubcategoryTemplateSetting> templates)
|
||||
{
|
||||
var categoryTemplateSlug = ExtractTemplateSlug(category.Slug, category.GroupName);
|
||||
return templates.Any(template =>
|
||||
string.Equals(category.Name, template.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(categoryTemplateSlug, template.Slug, StringComparison.OrdinalIgnoreCase)
|
||||
|| category.Slug.EndsWith($"-{template.Slug}", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static SeasonSubcategoryTemplateSetting[] Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SeasonSubcategoryTemplateSetting[]>(json, JsonOptions)?
|
||||
.Select(Normalize)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray()
|
||||
?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static SeasonSubcategoryTemplateSetting Normalize(SeasonSubcategoryTemplateSetting template)
|
||||
{
|
||||
var name = template.Name.Trim();
|
||||
var slug = Slugify(template.Slug);
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
slug = Slugify(name);
|
||||
}
|
||||
|
||||
return template with
|
||||
{
|
||||
Name = name,
|
||||
Slug = slug,
|
||||
SortOrder = Math.Clamp(template.SortOrder, 1, 99),
|
||||
ViewerRangeMin = NormalizeNullableNumber(template.ViewerRangeMin),
|
||||
ViewerRangeMax = NormalizeNullableNumber(template.ViewerRangeMax),
|
||||
};
|
||||
}
|
||||
|
||||
private static int? NormalizeNullableNumber(int? value) => value is null ? null : Math.Clamp(value.Value, 0, 100000);
|
||||
|
||||
private static SeasonSubcategoryTemplateSetting[] OnlyViewerTemplates(IEnumerable<SeasonSubcategoryTemplateSetting> templates)
|
||||
{
|
||||
var items = templates
|
||||
.Select(Normalize)
|
||||
.Where(item => item.ViewerRangeMin is not null || item.ViewerRangeMax is not null)
|
||||
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.OrderBy(item => item.SortOrder).First())
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
|
||||
return items.Length > 0 ? items : [];
|
||||
}
|
||||
|
||||
private static string ExtractTemplateSlug(string categorySlug, string groupName)
|
||||
{
|
||||
var groupSlug = Slugify(groupName);
|
||||
var slug = categorySlug.Trim().ToLowerInvariant();
|
||||
var prefix = string.IsNullOrWhiteSpace(groupSlug) ? string.Empty : $"{groupSlug}-";
|
||||
if (!string.IsNullOrWhiteSpace(prefix) && slug.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return slug[prefix.Length..];
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
public static string Slugify(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty)
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Normalize(NormalizationForm.FormD);
|
||||
|
||||
var builder = new StringBuilder(normalized.Length);
|
||||
var lastWasDash = false;
|
||||
|
||||
foreach (var character in normalized)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
builder.Append(character);
|
||||
lastWasDash = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastWasDash && builder.Length > 0)
|
||||
{
|
||||
builder.Append('-');
|
||||
lastWasDash = true;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString().Trim('-');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Text.Json;
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed record TrackingSourceSetting(
|
||||
string ProviderKey,
|
||||
string BaseUrl,
|
||||
string NotesSummary,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
public sealed record TrackingMetricRuleSetting(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string SourceSupport,
|
||||
string Description,
|
||||
bool RequiredForAutoClassification,
|
||||
bool ShowInReview,
|
||||
bool ShowInAdminSummary,
|
||||
bool ManualOverrideAllowed,
|
||||
string WindowKey,
|
||||
string[] AutoSupportedWindowKeys,
|
||||
string? ProviderFieldKey,
|
||||
int? TopCount,
|
||||
int? MinPrimaryCategorySharePercent,
|
||||
int? MinPrimaryCategoryHours,
|
||||
int? MaxDistinctCategoriesBeforeFlag,
|
||||
string[] IgnoredCategories,
|
||||
bool MatchAwardCategoryAgainstTopCategories,
|
||||
bool FlagIfAwardCategoryNotInTopX,
|
||||
bool FlagIfCategorySpreadTooWide,
|
||||
bool FlagIfNoCategoryContextAvailable,
|
||||
int? MinValue,
|
||||
int? MaxValue);
|
||||
|
||||
public sealed record TrackingFlagRuleSetting(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool AutoTriggerEnabled,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record TrackingRulesConfiguration(
|
||||
TrackingSourceSetting Source,
|
||||
TrackingMetricRuleSetting[] ImportantMetrics,
|
||||
TrackingMetricRuleSetting[] OptionalMetrics,
|
||||
TrackingFlagRuleSetting[] Flags);
|
||||
|
||||
public sealed record TrackingFlagHit(
|
||||
string Key,
|
||||
string Label,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public static class TrackingRulesSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public const string ProviderKey = "twitchtracker";
|
||||
public const string DefaultBaseUrl = "https://twitchtracker.com/api";
|
||||
|
||||
public const string Window7d = "7d";
|
||||
public const string Window30d = "30d";
|
||||
public const string Window90d = "90d";
|
||||
public const string WindowAllTime = "all_time";
|
||||
|
||||
public const string AvgViewers = "avg_viewers";
|
||||
public const string TrackerStatus = "tracker_status";
|
||||
public const string TrackerCheckedAt = "tracker_checked_at";
|
||||
public const string HoursStreamed = "hours_streamed";
|
||||
public const string HoursWatched = "hours_watched";
|
||||
public const string PeakViewers = "peak_viewers";
|
||||
public const string FollowersGained = "followers_gained";
|
||||
public const string CategoryFit = "category_fit";
|
||||
public const string TopCategoriesContext = "top_categories_context";
|
||||
|
||||
public const string FlagTrackerUnresolved = "tracker_unresolved";
|
||||
public const string FlagUnsupportedPlatform = "unsupported_platform";
|
||||
public const string FlagNoTrackerData = "no_tracker_data";
|
||||
public const string FlagMissingRequiredMetric = "missing_required_metric";
|
||||
public const string FlagManualReviewRequired = "manual_review_required";
|
||||
public const string FlagLowConfidenceSmallChannel = "low_confidence_small_channel";
|
||||
public const string FlagInsufficientActivityContext = "insufficient_activity_context";
|
||||
public const string FlagCategoryFitNeedsReview = "category_fit_needs_review";
|
||||
public const string FlagUnsupportedMetricWindow = "unsupported_metric_window";
|
||||
|
||||
public static readonly string[] SupportedMetricWindows = [Window7d, Window30d, Window90d, WindowAllTime];
|
||||
public static readonly string[] TwitchTrackerAutoWindowSupport = [Window30d];
|
||||
|
||||
public static TrackingSourceSetting DefaultSource { get; } =
|
||||
new(
|
||||
ProviderKey,
|
||||
DefaultBaseUrl,
|
||||
"TwitchTracker Basic API liefert aktuell Channel-Summary-Daten fuer 30 Tage. Andere Zeitfenster bleiben konfigurierbar, werden aber als manueller Review-Fall markiert.",
|
||||
true);
|
||||
|
||||
public static TrackingMetricRuleSetting[] DefaultImportantMetrics { get; } =
|
||||
[
|
||||
new(AvgViewers, "Avg Viewer", true, "auto", "Durchschnittliche Viewer fuer den gewaehlten Zeitraum.", true, true, true, true, Window90d, TwitchTrackerAutoWindowSupport, "avg_viewers", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(TrackerStatus, "Tracker-Status", true, "auto", "Zeigt, ob der TwitchTracker-Lookup sauber aufgeloest werden konnte.", true, true, true, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_status", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(TrackerCheckedAt, "Letzter Tracker-Check", true, "auto", "Zeitpunkt der letzten automatischen Datenaufloesung.", true, true, false, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_checked_at", null, null, null, null, [], false, false, false, false, null, null),
|
||||
];
|
||||
|
||||
public static TrackingMetricRuleSetting[] DefaultOptionalMetrics { get; } =
|
||||
[
|
||||
new(HoursStreamed, "Hours Streamed", true, "auto", "Gesamte Streamstunden im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_streamed", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(HoursWatched, "Hours Watched", true, "auto", "Gesamte Watch Time fuer den gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_watched", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(PeakViewers, "Peak Viewer", true, "auto", "Hoechster gleichzeitiger Zuschauerwert im Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "peak_viewers", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(FollowersGained, "Follower Growth", true, "auto", "Follower-Zuwachs im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "followers_gained", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(CategoryFit, "Category Fit", false, "context_only", "Admin-Einschaetzung, ob die Person inhaltlich zur Unterkategorie passt.", false, true, false, true, Window90d, [], null, null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(TopCategoriesContext, "Top Categories Context", false, "context_only", "Manueller Kontext aus zuletzt meistgestreamten Kategorien oder Games des Channels.", false, true, true, true, Window90d, [], null, 5, 60, 20, 6, ["Just Chatting", "Special Events"], true, true, true, true, null, null),
|
||||
];
|
||||
|
||||
public static TrackingFlagRuleSetting[] DefaultFlags { get; } =
|
||||
[
|
||||
new(FlagTrackerUnresolved, "Tracker-Link nicht aufloesbar", true, "high", "Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.", true, true, false, false),
|
||||
new(FlagUnsupportedPlatform, "Plattform nicht unterstuetzt", true, "medium", "Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.", true, true, false, false),
|
||||
new(FlagNoTrackerData, "Keine Tracker-Daten", true, "medium", "TwitchTracker hat keinen belastbaren Summary-Wert geliefert.", true, true, false, false),
|
||||
new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, true, false),
|
||||
new(FlagManualReviewRequired, "Manuelle Pruefung noetig", true, "medium", "Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.", true, true, false, false),
|
||||
new(FlagLowConfidenceSmallChannel, "Low Confidence Small Channel", false, "low", "Kleine Kanaele koennen manuell tiefer geprueft werden.", false, true, false, false),
|
||||
new(FlagInsufficientActivityContext, "Zu wenig Aktivitaetskontext", false, "low", "Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.", false, true, false, false),
|
||||
new(FlagCategoryFitNeedsReview, "Category Fit manuell pruefen", false, "low", "Unterkategorie muss inhaltlich manuell bestaetigt werden.", false, true, false, false),
|
||||
new(FlagUnsupportedMetricWindow, "Gewaehltes Zeitfenster nicht auto-verfuegbar", true, "medium", "Die aktuelle TwitchTracker API liefert diese Metrik nicht fuer das konfigurierte Zeitfenster.", true, true, false, false),
|
||||
];
|
||||
|
||||
public static TrackingRulesConfiguration Read(SiteSettings? settings)
|
||||
{
|
||||
var parsed = Parse(settings?.TrackingRulesJson);
|
||||
var source = NormalizeSource(parsed?.Source, DefaultSource);
|
||||
|
||||
return new TrackingRulesConfiguration(
|
||||
source,
|
||||
MergeMetrics(parsed?.ImportantMetrics, DefaultImportantMetrics),
|
||||
MergeMetrics(parsed?.OptionalMetrics, DefaultOptionalMetrics),
|
||||
MergeFlags(parsed?.Flags, DefaultFlags));
|
||||
}
|
||||
|
||||
public static string Serialize(TrackingRulesConfiguration configuration)
|
||||
{
|
||||
var normalized = new TrackingRulesConfiguration(
|
||||
NormalizeSource(configuration.Source, DefaultSource),
|
||||
MergeMetrics(configuration.ImportantMetrics, DefaultImportantMetrics),
|
||||
MergeMetrics(configuration.OptionalMetrics, DefaultOptionalMetrics),
|
||||
MergeFlags(configuration.Flags, DefaultFlags));
|
||||
|
||||
return JsonSerializer.Serialize(normalized, JsonOptions);
|
||||
}
|
||||
|
||||
public static TrackingMetricRuleSetting FindMetric(IEnumerable<TrackingMetricRuleSetting> rules, string key) =>
|
||||
rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
?? DefaultImportantMetrics.Concat(DefaultOptionalMetrics).First(item => item.Key == key);
|
||||
|
||||
public static TrackingFlagRuleSetting FindFlag(IEnumerable<TrackingFlagRuleSetting> flags, string key) =>
|
||||
flags.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
?? DefaultFlags.First(item => item.Key == key);
|
||||
|
||||
public static string NormalizeBaseUrl(string? rawValue)
|
||||
{
|
||||
var trimmed = (rawValue ?? string.Empty).Trim();
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return DefaultBaseUrl;
|
||||
}
|
||||
|
||||
return uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
}
|
||||
|
||||
public static string NormalizeWindowKey(string? value, string fallback)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return SupportedMetricWindows.Contains(normalized, StringComparer.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: fallback;
|
||||
}
|
||||
|
||||
public static string WindowLabel(string windowKey) =>
|
||||
NormalizeWindowKey(windowKey, Window30d) switch
|
||||
{
|
||||
Window7d => "7 Tage",
|
||||
Window30d => "30 Tage",
|
||||
Window90d => "3 Monate",
|
||||
WindowAllTime => "All Time",
|
||||
_ => "30 Tage",
|
||||
};
|
||||
|
||||
public static bool SupportsAutomaticWindow(TrackingMetricRuleSetting metric) =>
|
||||
metric.ProviderFieldKey is not null
|
||||
&& metric.AutoSupportedWindowKeys.Any(item => string.Equals(item, metric.WindowKey, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static TrackingFlagHit[] ReadFlagHits(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static string SerializeFlagHits(IEnumerable<TrackingFlagHit> flags) =>
|
||||
JsonSerializer.Serialize(flags, JsonOptions);
|
||||
|
||||
private static TrackingRulesConfigurationDto? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<TrackingRulesConfigurationDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static TrackingMetricRuleSetting[] MergeMetrics(
|
||||
IEnumerable<TrackingMetricRuleSetting>? storedRules,
|
||||
IEnumerable<TrackingMetricRuleSetting> defaults) =>
|
||||
defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
|
||||
return NormalizeMetric(storedRule, defaultRule);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private static TrackingFlagRuleSetting[] MergeFlags(
|
||||
IEnumerable<TrackingFlagRuleSetting>? storedRules,
|
||||
IEnumerable<TrackingFlagRuleSetting> defaults) =>
|
||||
defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
|
||||
return NormalizeFlag(storedRule, defaultRule);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private static TrackingSourceSetting NormalizeSource(TrackingSourceSetting? stored, TrackingSourceSetting fallback) =>
|
||||
new(
|
||||
fallback.ProviderKey,
|
||||
NormalizeBaseUrl(stored?.BaseUrl ?? fallback.BaseUrl),
|
||||
string.IsNullOrWhiteSpace(stored?.NotesSummary) ? fallback.NotesSummary : stored.NotesSummary.Trim(),
|
||||
stored?.ShowManualReviewNotesInReview ?? fallback.ShowManualReviewNotesInReview);
|
||||
|
||||
private static TrackingMetricRuleSetting NormalizeMetric(TrackingMetricRuleSetting? stored, TrackingMetricRuleSetting fallback)
|
||||
{
|
||||
if (stored is null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var sourceSupport = stored.SourceSupport.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"auto" => "auto",
|
||||
"manual" => "manual",
|
||||
"context_only" => "context_only",
|
||||
_ => fallback.SourceSupport,
|
||||
};
|
||||
|
||||
var autoSupportedWindowKeys = (stored.AutoSupportedWindowKeys ?? fallback.AutoSupportedWindowKeys)
|
||||
.Select(item => NormalizeWindowKey(item, Window30d))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return fallback with
|
||||
{
|
||||
Enabled = stored.Enabled,
|
||||
SourceSupport = sourceSupport,
|
||||
RequiredForAutoClassification = stored.RequiredForAutoClassification,
|
||||
ShowInReview = stored.ShowInReview,
|
||||
ShowInAdminSummary = stored.ShowInAdminSummary,
|
||||
ManualOverrideAllowed = stored.ManualOverrideAllowed,
|
||||
WindowKey = NormalizeWindowKey(stored.WindowKey, fallback.WindowKey),
|
||||
AutoSupportedWindowKeys = autoSupportedWindowKeys,
|
||||
ProviderFieldKey = string.IsNullOrWhiteSpace(stored.ProviderFieldKey) ? fallback.ProviderFieldKey : stored.ProviderFieldKey.Trim(),
|
||||
TopCount = stored.TopCount,
|
||||
MinPrimaryCategorySharePercent = stored.MinPrimaryCategorySharePercent,
|
||||
MinPrimaryCategoryHours = stored.MinPrimaryCategoryHours,
|
||||
MaxDistinctCategoriesBeforeFlag = stored.MaxDistinctCategoriesBeforeFlag,
|
||||
IgnoredCategories = (stored.IgnoredCategories ?? fallback.IgnoredCategories)
|
||||
.Select(item => item.Trim())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray(),
|
||||
MatchAwardCategoryAgainstTopCategories = stored.MatchAwardCategoryAgainstTopCategories,
|
||||
FlagIfAwardCategoryNotInTopX = stored.FlagIfAwardCategoryNotInTopX,
|
||||
FlagIfCategorySpreadTooWide = stored.FlagIfCategorySpreadTooWide,
|
||||
FlagIfNoCategoryContextAvailable = stored.FlagIfNoCategoryContextAvailable,
|
||||
MinValue = stored.MinValue,
|
||||
MaxValue = stored.MaxValue,
|
||||
};
|
||||
}
|
||||
|
||||
private static TrackingFlagRuleSetting NormalizeFlag(TrackingFlagRuleSetting? stored, TrackingFlagRuleSetting fallback)
|
||||
{
|
||||
if (stored is null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var severity = stored.Severity.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"high" => "high",
|
||||
"medium" => "medium",
|
||||
"low" => "low",
|
||||
_ => fallback.Severity,
|
||||
};
|
||||
|
||||
return fallback with
|
||||
{
|
||||
Enabled = stored.Enabled,
|
||||
Severity = severity,
|
||||
AutoTriggerEnabled = stored.AutoTriggerEnabled,
|
||||
RequiresManualReview = stored.RequiresManualReview,
|
||||
BlocksApproval = stored.BlocksApproval,
|
||||
AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record TrackingRulesConfigurationDto(
|
||||
TrackingSourceSetting? Source,
|
||||
TrackingMetricRuleSetting[]? ImportantMetrics,
|
||||
TrackingMetricRuleSetting[]? OptionalMetrics,
|
||||
TrackingFlagRuleSetting[]? Flags);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed class TwitchTrackerViewerStatsProvider(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AwardsDbContext db,
|
||||
ILogger<TwitchTrackerViewerStatsProvider> logger)
|
||||
: IViewerStatsProvider
|
||||
{
|
||||
public async Task<ViewerStatsSnapshot?> GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken)
|
||||
{
|
||||
var login = twitchLogin.Trim().TrimStart('@').ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(3));
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, timeout.Token);
|
||||
|
||||
var baseUrl = TrackingRulesSettings.NormalizeBaseUrl(settings?.ViewerStatsProviderBaseUrl);
|
||||
var client = httpClientFactory.CreateClient("TwitchTracker");
|
||||
var response = await client.GetFromJsonAsync<TwitchTrackerChannelSummaryResponse>(
|
||||
$"{baseUrl}/channels/summary/{Uri.EscapeDataString(login)}",
|
||||
timeout.Token);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ViewerStatsSnapshot(
|
||||
response.AverageViewers,
|
||||
Math.Max(0, response.MinutesStreamed / 60),
|
||||
response.HoursWatched,
|
||||
response.PeakViewers,
|
||||
response.FollowersGained,
|
||||
TrackingRulesSettings.Window30d);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("TwitchTracker API lookup timed out for {TwitchLogin}.", login);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogInformation(ex, "TwitchTracker API lookup failed for {TwitchLogin}.", login);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TwitchTrackerChannelSummaryResponse
|
||||
{
|
||||
[JsonPropertyName("rank")]
|
||||
public int Rank { get; init; }
|
||||
|
||||
[JsonPropertyName("minutes_streamed")]
|
||||
public int MinutesStreamed { get; init; }
|
||||
|
||||
[JsonPropertyName("avg_viewers")]
|
||||
public int AverageViewers { get; init; }
|
||||
|
||||
[JsonPropertyName("max_viewers")]
|
||||
public int PeakViewers { get; init; }
|
||||
|
||||
[JsonPropertyName("hours_watched")]
|
||||
public int HoursWatched { get; init; }
|
||||
|
||||
[JsonPropertyName("followers")]
|
||||
public int FollowersGained { get; init; }
|
||||
|
||||
[JsonPropertyName("followers_total")]
|
||||
public int FollowersTotal { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Repositories;
|
||||
using Backend.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
|
||||
public sealed class UserSessionService(IUserSessionRepository userSessionRepository, AwardsDbContext db) : IUserSessionService
|
||||
{
|
||||
private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12);
|
||||
public const int MinimumIdleTimeoutHours = 3;
|
||||
public const int DefaultIdleTimeoutHours = 3;
|
||||
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
|
||||
@@ -27,7 +30,8 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (IsExpired(session, now))
|
||||
var idleSessionLifetime = await ResolveIdleSessionLifetimeAsync(cancellationToken);
|
||||
if (IsExpired(session, now, idleSessionLifetime))
|
||||
{
|
||||
session.IsActive = false;
|
||||
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
||||
@@ -40,6 +44,17 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var configuredHours = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Id == 1)
|
||||
.Select(item => (int?)item.SessionIdleTimeoutHours)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return NormalizeIdleTimeoutHours(configuredHours);
|
||||
}
|
||||
|
||||
public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CreateSessionAsync(
|
||||
@@ -94,9 +109,18 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsExpired(UserSession session, DateTimeOffset now) =>
|
||||
private async Task<TimeSpan> ResolveIdleSessionLifetimeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var idleTimeoutHours = await GetIdleTimeoutHoursAsync(cancellationToken);
|
||||
return TimeSpan.FromHours(idleTimeoutHours);
|
||||
}
|
||||
|
||||
public static int NormalizeIdleTimeoutHours(int? configuredHours) =>
|
||||
Math.Max(MinimumIdleTimeoutHours, configuredHours ?? DefaultIdleTimeoutHours);
|
||||
|
||||
private static bool IsExpired(UserSession session, DateTimeOffset now, TimeSpan idleSessionLifetime) =>
|
||||
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|
||||
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime);
|
||||
|| session.LastSeenAt <= now.Subtract(idleSessionLifetime);
|
||||
|
||||
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class WorkflowRuleSettings
|
||||
public const string MaxCandidateAppearances = "max_candidate_appearances";
|
||||
public const string MaxWinnerPlacements = "max_winner_placements";
|
||||
public const string WinnerRequiresClip = "winner_requires_clip";
|
||||
public const string RecommendedNominatorsPerSubcategory = "recommended_nominators_per_subcategory";
|
||||
|
||||
public static WorkflowRuleSetting[] Defaults { get; } =
|
||||
[
|
||||
@@ -26,11 +27,28 @@ public static class WorkflowRuleSettings
|
||||
new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."),
|
||||
new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."),
|
||||
new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."),
|
||||
new(RecommendedNominatorsPerSubcategory, "Empfohlene Nominierer pro Unterkategorie", true, 4, "warn", "Zeigt in der Kategorie-Übersicht an, ab wann eine Unterkategorie nominierungsseitig gut getragen ist. Diese Regel blockiert nichts."),
|
||||
];
|
||||
|
||||
public static WorkflowRuleSetting[] Read(SiteSettings? settings)
|
||||
{
|
||||
var storedRules = Parse(settings?.WorkflowRulesJson);
|
||||
return Read(storedRules);
|
||||
}
|
||||
|
||||
public static WorkflowRuleSetting[] Read(Season? season, SiteSettings? settings)
|
||||
{
|
||||
var seasonRules = Parse(season?.WorkflowRulesJson);
|
||||
if (seasonRules.Length > 0)
|
||||
{
|
||||
return Read(seasonRules);
|
||||
}
|
||||
|
||||
return Read(settings);
|
||||
}
|
||||
|
||||
private static WorkflowRuleSetting[] Read(WorkflowRuleSetting[] storedRules)
|
||||
{
|
||||
return Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
@@ -98,6 +116,11 @@ public static class WorkflowRuleSettings
|
||||
_ => fallback.Mode,
|
||||
};
|
||||
|
||||
if (string.Equals(fallback.Key, RecommendedNominatorsPerSubcategory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mode = "warn";
|
||||
}
|
||||
|
||||
return rule with
|
||||
{
|
||||
Key = fallback.Key,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, reactive, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppShellAccountModals from './AppShellAccountModals.vue'
|
||||
import AdminToastViewport from './admin/AdminToastViewport.vue'
|
||||
import { useAdminApiDisconnectToast } from '../composables/useAdminApiDisconnectToast'
|
||||
import { useBodyScrollLock } from '../composables/useBodyScrollLock'
|
||||
import { privacyContentToHtml } from '../lib/privacyContent'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -35,6 +37,7 @@ const navItems = [
|
||||
]
|
||||
|
||||
const visibleNavItems = computed(() => navItems)
|
||||
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||
const privacyContent = computed(
|
||||
() => 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 linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
|
||||
|
||||
useAdminApiDisconnectToast(isAdminRoute)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -231,6 +235,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
||||
/>
|
||||
</template>
|
||||
|
||||
<AdminToastViewport />
|
||||
</div>
|
||||
</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)" />
|
||||
</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">
|
||||
<div class="mb-3">
|
||||
<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 }>
|
||||
clipEmbedStatusOptions: Array<{ label: string; value: string }>
|
||||
selectedPlatformValue: string
|
||||
identitySummary: {
|
||||
appearances: number
|
||||
acceptedAppearances: number
|
||||
winnerPlacements: number
|
||||
nominationTally: number
|
||||
} | null
|
||||
ruleNotices: Array<{ mode: 'warn' | 'block'; message: string }>
|
||||
canSave: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</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">
|
||||
Mögliches Duplikat in dieser Kategorie
|
||||
</p>
|
||||
@@ -122,6 +129,7 @@ const props = defineProps<{
|
||||
rangeEnd: number
|
||||
categoryLabelMap: Record<number, string>
|
||||
duplicateCandidateKeys: Map<string, number>
|
||||
candidateIdentitySummaries: Record<string, { appearances: number; acceptedAppearances: number; winnerPlacements: number; nominationTally: number }>
|
||||
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: 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
|
||||
}
|
||||
|
||||
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) {
|
||||
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>
|
||||
<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 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>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
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 type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
|
||||
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 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 loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const localSaving = ref(false)
|
||||
const statusMessage = 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 selectedShowactId = ref<number | null>(null)
|
||||
const showactNotes = reactive<Record<number, string>>({})
|
||||
const settingsForm = reactive({
|
||||
showactApplicationsEnabled: false,
|
||||
showactApplicationStartsAt: '',
|
||||
showactApplicationEndsAt: '',
|
||||
showactApplicationsOpenNow: false,
|
||||
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
|
||||
sponsorsVisible: true,
|
||||
})
|
||||
@@ -33,17 +52,108 @@ const sponsorForm = reactive<UpsertSponsorPayload>({
|
||||
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
|
||||
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').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(
|
||||
() => store.adminOptionalFeatureSettings,
|
||||
(settings) => {
|
||||
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.sponsorsVisible = settings.sponsorsVisible
|
||||
},
|
||||
{ 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() {
|
||||
editingSponsorId.value = null
|
||||
sponsorForm.name = ''
|
||||
@@ -92,31 +202,41 @@ async function loadLandingExtras() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLandingExtrasSettings() {
|
||||
saving.value = true
|
||||
async function saveAll() {
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
await store.updateAdminOptionalFeatureSettings({
|
||||
if (showactScheduleError.value) {
|
||||
throw new Error(showactScheduleError.value)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
store.updateAdminOptionalFeatureSettings({
|
||||
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
|
||||
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
|
||||
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,
|
||||
})
|
||||
statusMessage.value = 'Landingpage-Extras wurden gespeichert.'
|
||||
}),
|
||||
props.saveSiteSettings(props.section === 'showacts' ? 'Showact-Inhalt' : 'Sponsoren-Inhalt'),
|
||||
])
|
||||
statusMessage.value = 'Einstellungen gespeichert.'
|
||||
} 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 {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSponsor() {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -131,14 +251,14 @@ async function saveSponsor() {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSponsor(sponsor: AdminSponsorItem) {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -148,14 +268,14 @@ async function deleteSponsor(sponsor: AdminSponsorItem) {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateShowactStatus(applicationId: number, status: string) {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -167,14 +287,14 @@ async function updateShowactStatus(applicationId: number, status: string) {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteShowact(applicationId: number, artistName: string) {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -184,7 +304,7 @@ async function deleteShowact(applicationId: number, artistName: string) {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,79 +317,128 @@ function statusLabel(status: string) {
|
||||
}[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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card id="content-landing-extras" class="p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Landingpage Extras</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Showacts und Sponsoren</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
Module, Bewerbungen und Sponsoren direkt dort steuern, wo sie public erscheinen.
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p class="text-sm text-slate-500">
|
||||
<template v-if="props.section === 'showacts'">
|
||||
{{ store.adminShowactApplications.length }} Bewerbungen · {{ pendingShowacts }} offen
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ store.adminSponsors.length }} Sponsoren · {{ visibleSponsors }} sichtbar
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<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" />
|
||||
{{ loading ? 'Laedt ...' : 'Neu laden' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-fuchsia-100 bg-fuchsia-50/55 p-4">
|
||||
<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">
|
||||
<!-- Showacts section -->
|
||||
<template v-if="props.section === 'showacts'">
|
||||
<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">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>
|
||||
<AdminSettingsToggle
|
||||
:model-value="settingsForm.showactApplicationsEnabled"
|
||||
label="Showact-Bewerbungen aktivieren"
|
||||
active-label="Offen"
|
||||
inactive-label="Zu"
|
||||
active-label="Bewerbung offen"
|
||||
inactive-label="Bewerbung geschlossen"
|
||||
@update:model-value="settingsForm.showactApplicationsEnabled = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Starttermin (optional)</span>
|
||||
<input
|
||||
v-model="settingsForm.showactApplicationStartsAt"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</label>
|
||||
|
||||
<label class="mt-4 block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Showact-Bewerbungen</span>
|
||||
<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>
|
||||
|
||||
<p v-if="showactScheduleError" class="text-sm font-semibold text-rose-700">
|
||||
{{ showactScheduleError }}
|
||||
</p>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Bewerbungen</span>
|
||||
<textarea
|
||||
v-model="settingsForm.showactApplicationDisabledMessage"
|
||||
rows="3"
|
||||
@@ -278,16 +447,21 @@ onMounted(loadLandingExtras)
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<Button class="gap-2" :disabled="saving" @click="saveLandingExtrasSettings">
|
||||
<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" />
|
||||
{{ saving ? 'Speichert ...' : 'Landingpage Extras speichern' }}
|
||||
{{ (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>
|
||||
|
||||
<section class="mt-7 grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)]">
|
||||
<AdminShowactFormBuilder
|
||||
:form="props.form"
|
||||
:saving="props.saving"
|
||||
:save-site-settings="props.saveSiteSettings"
|
||||
/>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
||||
<div class="border-b border-violet-100 p-4">
|
||||
<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>
|
||||
</div>
|
||||
<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" 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)">
|
||||
@@ -317,6 +491,19 @@ onMounted(loadLandingExtras)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic field responses if available, otherwise legacy fixed fields -->
|
||||
<template v-if="application.fieldResponsesJson && application.fieldResponsesJson !== '{}'">
|
||||
<div class="grid gap-2 text-sm text-slate-600 md:grid-cols-2">
|
||||
<template v-for="(value, key) in (() => { try { return JSON.parse(application.fieldResponsesJson) } catch { return {} } })()" :key="key">
|
||||
<div v-if="value" class="flex flex-col gap-0.5">
|
||||
<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>
|
||||
@@ -324,6 +511,8 @@ onMounted(loadLandingExtras)
|
||||
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
|
||||
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<textarea
|
||||
v-model="showactNotes[application.id]"
|
||||
rows="2"
|
||||
@@ -334,6 +523,47 @@ onMounted(loadLandingExtras)
|
||||
</article>
|
||||
</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="border-b border-violet-100 p-4">
|
||||
@@ -388,6 +618,54 @@ onMounted(loadLandingExtras)
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
</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
|
||||
v-model="form.privacyPolicyContent"
|
||||
class="mt-6"
|
||||
class="mt-4"
|
||||
label="Datenschutz Inhalt"
|
||||
placeholder="Datenschutztext..."
|
||||
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">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { Sparkles, X } from '@lucide/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 { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
const props = defineProps<{
|
||||
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<{
|
||||
close: []
|
||||
'platform-change': [value: string]
|
||||
approve: [nominationId: number]
|
||||
reject: [nominationId: number]
|
||||
reviewFlagged: [nominationId: number]
|
||||
overrideFlagged: [nominationId: number]
|
||||
'blacklist-link': [streamUrl: string]
|
||||
}>()
|
||||
|
||||
const {
|
||||
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)
|
||||
const modalTitle = computed(() => props.nomination?.displayName || 'Nominierung entscheiden')
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
@@ -84,7 +83,7 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p>
|
||||
</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">
|
||||
Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme.
|
||||
</p>
|
||||
@@ -99,84 +98,31 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<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"
|
||||
:review-stats="reviewStats"
|
||||
:category-options="categoryOptions"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="min-h-0 overflow-y-auto p-4 sm:p-6">
|
||||
<AdminReviewDecisionPanel
|
||||
:nomination="selectedNomination"
|
||||
:nomination="nomination"
|
||||
:review-saving="reviewSaving"
|
||||
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
|
||||
:review-form="reviewForm"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:category-options="categoryOptions"
|
||||
:selected-candidate-collision="selectedCandidateCollision"
|
||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||
:signal-summary="selectedNominationSignalSummary"
|
||||
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
||||
: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"
|
||||
@platform-change="handlePlatformSelection"
|
||||
@approve="approveNomination"
|
||||
@reject="rejectNomination"
|
||||
@blacklist-link="addStreamUrlToBlacklist"
|
||||
: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>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||
@@ -225,6 +225,39 @@ function toneClasses(tone: AdminSettingsTone) {
|
||||
</div>
|
||||
</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'">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<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">
|
||||
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 Card from '../ui/Card.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
const changelogOpen = ref(false)
|
||||
const expandedVersions = ref<string[]>([releaseVersions[0]?.version ?? semanticVersion])
|
||||
|
||||
const featureCount = computed(() => releaseNotes.filter((note) => note.type === 'feature').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) {
|
||||
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>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-600">Stabilitaet, UI-Feedback und Backend-Hygiene.</p>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -115,12 +143,57 @@ function changeTypeClasses(type: ReleaseChangeType) {
|
||||
<section>
|
||||
<div class="flex items-center gap-2">
|
||||
<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 class="mt-3 space-y-3">
|
||||
<section
|
||||
v-for="version in releaseVersions"
|
||||
:key="version.version"
|
||||
class="overflow-hidden rounded-[24px] border border-violet-100 bg-white/95 shadow-[0_16px_36px_rgba(124,92,255,0.08)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
@click="toggleVersion(version.version)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-slate-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
|
||||
{{ version.version }}
|
||||
</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>
|
||||
</div>
|
||||
<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 releaseNotes"
|
||||
:key="note.title"
|
||||
v-for="note in version.notes"
|
||||
:key="`${version.version}-${note.title}`"
|
||||
class="rounded-2xl border p-4"
|
||||
:class="changeTypeClasses(note.type).panel"
|
||||
>
|
||||
@@ -142,6 +215,9 @@ function changeTypeClasses(type: ReleaseChangeType) {
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-start gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<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 NativeSelect from '../ui/NativeSelect.vue'
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nomination: AdminNominationReviewItem | null
|
||||
const props = defineProps<{
|
||||
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?: {
|
||||
@@ -22,51 +24,97 @@ defineProps<{
|
||||
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<{
|
||||
'platform-change': [value: string]
|
||||
approve: [nominationId: number]
|
||||
reject: [nominationId: number]
|
||||
reviewFlagged: [nominationId: number]
|
||||
overrideFlagged: [nominationId: number]
|
||||
'blacklist-link': [streamUrl: string]
|
||||
}>()
|
||||
|
||||
function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||
return nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')?.windowLabel?.trim() ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<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="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-[linear-gradient(135deg,#fcfaff_0%,#ffffff_100%)] p-4 sm: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.22em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-violet-100 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-violet-800">
|
||||
{{ nomination.categoryGroupName }}
|
||||
</span>
|
||||
<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">
|
||||
Gruppe #{{ nomination.id }}
|
||||
</span>
|
||||
</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 class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
|
||||
ID {{ nomination.id }}
|
||||
|
||||
<div
|
||||
class="rounded-2xl border px-4 py-3 text-sm"
|
||||
:class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
|
||||
>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em]">Tracker</p>
|
||||
<p class="mt-1 font-semibold">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<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">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tier-Vorschlag</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ nomination.suggestedCategoryName || 'manuell wählen' }}</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">Avg Viewer <span v-if="avgViewerWindowLabel(nomination)">· {{ avgViewerWindowLabel(nomination) }}</span></p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ nomination.avgViewers ?? 'offen' }}</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">Plattformen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ signalSummary?.platforms.join(', ') || nomination.resolvedPlatform || 'keine' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<div class="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link2 class="h-4 w-4 text-sky-700" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Quelle & Signale</p>
|
||||
</div>
|
||||
|
||||
<div v-if="streamUrl" class="mt-3 rounded-2xl border border-sky-100 bg-white p-3">
|
||||
<a
|
||||
:href="streamUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
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"
|
||||
class="block truncate text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
||||
>
|
||||
<span class="truncate">{{ streamUrl }}</span>
|
||||
{{ streamUrl }}
|
||||
</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-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
|
||||
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)"
|
||||
>
|
||||
@@ -77,24 +125,91 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<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">Einreichungen</p>
|
||||
<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 User</p>
|
||||
<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">Plattformen</p>
|
||||
<strong class="mt-1 block truncate text-sm text-violet-900">{{ signalSummary.platforms.join(', ') || 'keine' }}</strong>
|
||||
<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 class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<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
|
||||
@@ -113,6 +228,14 @@ const emit = defineEmits<{
|
||||
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
|
||||
@@ -125,6 +248,7 @@ const emit = defineEmits<{
|
||||
/>
|
||||
</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
|
||||
@@ -134,40 +258,57 @@ const emit = defineEmits<{
|
||||
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 dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
|
||||
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in diesem Tier bereits vorhanden.
|
||||
</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.
|
||||
<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>
|
||||
<label class="mt-3 block space-y-2">
|
||||
</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="3"
|
||||
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>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="emit('reject', nomination.id)">
|
||||
<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" @click="emit('approve', nomination.id)">
|
||||
<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>
|
||||
</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">
|
||||
import { RotateCcw } from '@lucide/vue'
|
||||
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
reviewedTotal: number
|
||||
reviewSaving?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'undo-rejected': [nominationId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -28,9 +35,21 @@ defineProps<{
|
||||
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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'">
|
||||
{{ 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>
|
||||
<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">
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@lucide/vue'
|
||||
import { Ban, Search } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
reviewFilter: string
|
||||
categoryFilter: number | null
|
||||
categoryFilter: string | null
|
||||
totalPending: number
|
||||
visibleCount: number
|
||||
reviewStats: Array<{ label: string; value: number }>
|
||||
categoryOptions: Array<{ id: number; label: string; count: number }>
|
||||
categoryOptions: Array<{ id: string; label: string; count: number }>
|
||||
activeMainCategory?: string | null
|
||||
showCategoryFilterChips?: boolean
|
||||
showBlacklistButton?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:reviewFilter': [value: string]
|
||||
'update:categoryFilter': [value: number | null]
|
||||
'update:categoryFilter': [value: string | null]
|
||||
'open-blacklist': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Offene Nominierungen</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
|
||||
</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>
|
||||
<h2 class="mt-1 text-2xl font-bold text-slate-900">{{ activeMainCategory || 'Offene Nominierungen' }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Offene Nominierungen</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<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" />
|
||||
<input
|
||||
@@ -45,11 +50,9 @@ const emit = defineEmits<{
|
||||
@input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</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 class="mt-3 flex flex-wrap gap-2">
|
||||
|
||||
<div v-if="showCategoryFilterChips !== false" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
|
||||
@@ -1,42 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
import type { AdminNominationReviewGroup } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nominations: AdminNominationReviewItem[]
|
||||
const props = defineProps<{
|
||||
nominations: AdminNominationReviewGroup[]
|
||||
totalPending: number
|
||||
selectedNominationId: number | null
|
||||
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
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>
|
||||
|
||||
<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
|
||||
v-for="nomination in nominations"
|
||||
:key="nomination.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border p-3 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="w-full rounded-[24px] border p-4 text-left transition"
|
||||
: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)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<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">
|
||||
{{ nomination.categoryName }}
|
||||
<span
|
||||
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 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>
|
||||
</div>
|
||||
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">
|
||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
<h3 class="mt-2 line-clamp-2 text-base font-semibold text-slate-900">{{ nomination.displayName || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs font-semibold text-slate-500">
|
||||
<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>
|
||||
</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>
|
||||
</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
Reference in New Issue
Block a user