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(
|
public sealed record AdminNominationReviewItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
int CategoryId,
|
int? CategoryId,
|
||||||
|
string CategoryGroupName,
|
||||||
string CategoryName,
|
string CategoryName,
|
||||||
string SubmittedByTwitchId,
|
string SubmittedByTwitchId,
|
||||||
string CandidateText,
|
string CandidateText,
|
||||||
string? StreamUrl,
|
string? StreamUrl,
|
||||||
|
string? ResolvedChannel,
|
||||||
|
string? ResolvedPlatform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int? SuggestedCategoryId,
|
||||||
|
string? SuggestedCategoryName,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string TrackerStatus,
|
||||||
|
DateTimeOffset? TrackerCheckedAt,
|
||||||
|
string TrackingReviewStatus,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||||
|
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||||
|
string? TrackingReviewNote,
|
||||||
|
string? TrackingReviewedByTwitchId,
|
||||||
|
DateTimeOffset? TrackingReviewedAt,
|
||||||
string Status,
|
string Status,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
int? CandidateId,
|
int? CandidateId,
|
||||||
@@ -68,6 +84,32 @@ public sealed record AdminNominationReviewItemDto(
|
|||||||
string? ReviewedByTwitchId,
|
string? ReviewedByTwitchId,
|
||||||
DateTimeOffset? ReviewedAt);
|
DateTimeOffset? ReviewedAt);
|
||||||
|
|
||||||
|
public sealed record AdminNominationReviewGroupDto(
|
||||||
|
int Id,
|
||||||
|
int[] NominationIds,
|
||||||
|
string CategoryGroupName,
|
||||||
|
string DisplayName,
|
||||||
|
string? StreamUrl,
|
||||||
|
string? ResolvedChannel,
|
||||||
|
string? ResolvedPlatform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int? SuggestedCategoryId,
|
||||||
|
string? SuggestedCategoryName,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string TrackerStatus,
|
||||||
|
DateTimeOffset? TrackerCheckedAt,
|
||||||
|
string TrackingReviewStatus,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||||
|
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||||
|
string? TrackingReviewNote,
|
||||||
|
string? TrackingReviewedByTwitchId,
|
||||||
|
DateTimeOffset? TrackingReviewedAt,
|
||||||
|
int NominationTally,
|
||||||
|
int UniqueSubmitterCount,
|
||||||
|
DateTimeOffset FirstSubmittedAt,
|
||||||
|
DateTimeOffset LastSubmittedAt);
|
||||||
|
|
||||||
public sealed record AdminClipSubmissionItemDto(
|
public sealed record AdminClipSubmissionItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
int? CategoryId,
|
int? CategoryId,
|
||||||
@@ -87,10 +129,17 @@ public sealed record ApproveNominationRequest(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? ChannelSlug,
|
string? ChannelSlug,
|
||||||
string? Platform,
|
string? Platform,
|
||||||
|
int? CategoryId,
|
||||||
string? ReviewNote);
|
string? ReviewNote);
|
||||||
|
|
||||||
public sealed record RejectNominationRequest(string? ReviewNote);
|
public sealed record RejectNominationRequest(string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record ReopenRejectedNominationRequest(string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record UpdateNominationTrackingReviewRequest(
|
||||||
|
string Status,
|
||||||
|
string? ReviewNote);
|
||||||
|
|
||||||
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
|
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
|
||||||
|
|
||||||
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
|
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
|
||||||
@@ -136,3 +185,24 @@ public sealed record AdminWorkflowRuleDto(
|
|||||||
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
|
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
|
||||||
|
|
||||||
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
|
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingFlagHitDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string Severity,
|
||||||
|
string Description,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
bool BlocksApproval,
|
||||||
|
bool AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingMetricStateDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Required,
|
||||||
|
string SourceSupport,
|
||||||
|
bool Present,
|
||||||
|
string Value,
|
||||||
|
string Description,
|
||||||
|
string WindowKey,
|
||||||
|
string WindowLabel,
|
||||||
|
bool AutoWindowSupported);
|
||||||
|
|||||||
@@ -16,14 +16,25 @@ public sealed record AdminCategoryItemDto(
|
|||||||
string Description,
|
string Description,
|
||||||
int SortOrder,
|
int SortOrder,
|
||||||
int MaxNomineesPerUser,
|
int MaxNomineesPerUser,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax,
|
||||||
int CandidateCount);
|
int CandidateCount);
|
||||||
|
|
||||||
|
public sealed record AdminSubcategoryTemplateDto(
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
int SortOrder,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax);
|
||||||
|
|
||||||
public sealed record AdminCandidateItemDto(
|
public sealed record AdminCandidateItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
int CategoryId,
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
string ChannelSlug,
|
||||||
string Platform,
|
string Platform,
|
||||||
|
int NominationTally,
|
||||||
string AcceptanceStatus,
|
string AcceptanceStatus,
|
||||||
string? AcceptanceNote,
|
string? AcceptanceNote,
|
||||||
string? ClipCompilationUrl,
|
string? ClipCompilationUrl,
|
||||||
@@ -36,6 +47,7 @@ public sealed record AdminAwardResultItemDto(
|
|||||||
int CategoryId,
|
int CategoryId,
|
||||||
string CategoryName,
|
string CategoryName,
|
||||||
int CandidateId,
|
int CandidateId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
string CandidateDisplayName,
|
string CandidateDisplayName,
|
||||||
string CandidateChannelSlug,
|
string CandidateChannelSlug,
|
||||||
string CandidatePlatform);
|
string CandidatePlatform);
|
||||||
@@ -56,10 +68,14 @@ public sealed record AdminSeasonDetailResponse(
|
|||||||
DateOnly ReviewEndsAt,
|
DateOnly ReviewEndsAt,
|
||||||
DateOnly ShowDate,
|
DateOnly ShowDate,
|
||||||
TimeOnly ShowStartsAt,
|
TimeOnly ShowStartsAt,
|
||||||
|
IEnumerable<AdminSubcategoryTemplateDto> SubcategoryTemplates,
|
||||||
IEnumerable<AdminCategoryItemDto> Categories,
|
IEnumerable<AdminCategoryItemDto> Categories,
|
||||||
IEnumerable<AdminCandidateItemDto> Candidates,
|
IEnumerable<AdminCandidateItemDto> Candidates,
|
||||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
||||||
|
IEnumerable<AdminNominationReviewGroupDto> PendingNominationGroups,
|
||||||
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
||||||
|
string TrackingReviewNotes,
|
||||||
|
bool ShowTrackingReviewNotes,
|
||||||
IEnumerable<AdminAwardResultItemDto> Results,
|
IEnumerable<AdminAwardResultItemDto> Results,
|
||||||
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
||||||
|
|
||||||
@@ -102,6 +118,17 @@ public sealed record UpsertCategoryRequest(
|
|||||||
string Slug,
|
string Slug,
|
||||||
string Description,
|
string Description,
|
||||||
int SortOrder,
|
int SortOrder,
|
||||||
|
int MaxNomineesPerUser,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax);
|
||||||
|
|
||||||
|
public sealed record UpdateSeasonSubcategoryTemplatesRequest(
|
||||||
|
AdminSubcategoryTemplateDto[] Templates);
|
||||||
|
|
||||||
|
public sealed record UpsertCategoryGroupRequest(
|
||||||
|
string GroupName,
|
||||||
|
string Description,
|
||||||
|
int SortOrder,
|
||||||
int MaxNomineesPerUser);
|
int MaxNomineesPerUser);
|
||||||
|
|
||||||
public sealed record UpsertCandidateRequest(
|
public sealed record UpsertCandidateRequest(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public sealed record AdminSiteSettingsResponse(
|
|||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
string PrivacyEmail,
|
string PrivacyEmail,
|
||||||
string PrivacyPolicyContent,
|
string PrivacyPolicyContent,
|
||||||
string? PrivacyPolicyUpdatedBy,
|
string? PrivacyPolicyUpdatedBy,
|
||||||
@@ -17,12 +19,15 @@ public sealed record AdminSiteSettingsResponse(
|
|||||||
string ShowactsUrl,
|
string ShowactsUrl,
|
||||||
string ShowactsContent,
|
string ShowactsContent,
|
||||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||||
IEnumerable<FaqItemDto> Faq);
|
IEnumerable<FaqItemDto> Faq,
|
||||||
|
string ShowactFormSchemaJson);
|
||||||
|
|
||||||
public sealed record UpdateSiteSettingsRequest(
|
public sealed record UpdateSiteSettingsRequest(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
string PrivacyEmail,
|
string PrivacyEmail,
|
||||||
string PrivacyPolicyContent,
|
string PrivacyPolicyContent,
|
||||||
string ImprintUrl,
|
string ImprintUrl,
|
||||||
@@ -34,7 +39,8 @@ public sealed record UpdateSiteSettingsRequest(
|
|||||||
string ShowactsUrl,
|
string ShowactsUrl,
|
||||||
string ShowactsContent,
|
string ShowactsContent,
|
||||||
PublicSocialLinkDto[] SocialLinks,
|
PublicSocialLinkDto[] SocialLinks,
|
||||||
FaqItemDto[] Faq);
|
FaqItemDto[] Faq,
|
||||||
|
string? ShowactFormSchemaJson = null);
|
||||||
|
|
||||||
public sealed record AdminOperationalSettingsResponse(
|
public sealed record AdminOperationalSettingsResponse(
|
||||||
bool DemoLoginManagedByDatabase,
|
bool DemoLoginManagedByDatabase,
|
||||||
@@ -49,6 +55,7 @@ public sealed record AdminOperationalSettingsResponse(
|
|||||||
bool TwitchClientSecretSet,
|
bool TwitchClientSecretSet,
|
||||||
string TwitchRedirectUri,
|
string TwitchRedirectUri,
|
||||||
string TwitchScope,
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
@@ -59,6 +66,9 @@ public sealed record AdminOptionalFeatureSettingsResponse(
|
|||||||
bool ClipAdminMenuVisible,
|
bool ClipAdminMenuVisible,
|
||||||
string ClipSubmissionDisabledMessage,
|
string ClipSubmissionDisabledMessage,
|
||||||
bool ShowactApplicationsEnabled,
|
bool ShowactApplicationsEnabled,
|
||||||
|
DateOnly? ShowactApplicationStartsAt,
|
||||||
|
DateOnly? ShowactApplicationEndsAt,
|
||||||
|
bool ShowactApplicationsOpenNow,
|
||||||
string ShowactApplicationDisabledMessage,
|
string ShowactApplicationDisabledMessage,
|
||||||
bool SponsorsVisible);
|
bool SponsorsVisible);
|
||||||
|
|
||||||
@@ -68,6 +78,8 @@ public sealed record UpdateOptionalFeatureSettingsRequest(
|
|||||||
bool ClipAdminMenuVisible,
|
bool ClipAdminMenuVisible,
|
||||||
string ClipSubmissionDisabledMessage,
|
string ClipSubmissionDisabledMessage,
|
||||||
bool ShowactApplicationsEnabled,
|
bool ShowactApplicationsEnabled,
|
||||||
|
DateOnly? ShowactApplicationStartsAt,
|
||||||
|
DateOnly? ShowactApplicationEndsAt,
|
||||||
string ShowactApplicationDisabledMessage,
|
string ShowactApplicationDisabledMessage,
|
||||||
bool SponsorsVisible);
|
bool SponsorsVisible);
|
||||||
|
|
||||||
@@ -81,6 +93,71 @@ public sealed record UpdateOperationalSettingsRequest(
|
|||||||
string? TwitchClientSecret,
|
string? TwitchClientSecret,
|
||||||
string TwitchRedirectUri,
|
string TwitchRedirectUri,
|
||||||
string TwitchScope,
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingSourceDto(
|
||||||
|
string ProviderKey,
|
||||||
|
string ProviderLabel,
|
||||||
|
string BaseUrl,
|
||||||
|
string NotesSummary,
|
||||||
|
bool ShowManualReviewNotesInReview);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingMetricRuleDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Enabled,
|
||||||
|
string SourceSupport,
|
||||||
|
string Description,
|
||||||
|
bool RequiredForAutoClassification,
|
||||||
|
bool ShowInReview,
|
||||||
|
bool ShowInAdminSummary,
|
||||||
|
bool ManualOverrideAllowed,
|
||||||
|
string WindowKey,
|
||||||
|
string[] AutoSupportedWindowKeys,
|
||||||
|
string? ProviderFieldKey,
|
||||||
|
int? TopCount,
|
||||||
|
int? MinPrimaryCategorySharePercent,
|
||||||
|
int? MinPrimaryCategoryHours,
|
||||||
|
int? MaxDistinctCategoriesBeforeFlag,
|
||||||
|
string[] IgnoredCategories,
|
||||||
|
bool MatchAwardCategoryAgainstTopCategories,
|
||||||
|
bool FlagIfAwardCategoryNotInTopX,
|
||||||
|
bool FlagIfCategorySpreadTooWide,
|
||||||
|
bool FlagIfNoCategoryContextAvailable,
|
||||||
|
int? MinValue,
|
||||||
|
int? MaxValue);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingFlagRuleDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Enabled,
|
||||||
|
string Severity,
|
||||||
|
string Description,
|
||||||
|
bool AutoTriggerEnabled,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
bool BlocksApproval,
|
||||||
|
bool AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingRulesResponse(
|
||||||
|
AdminTrackingSourceDto Source,
|
||||||
|
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||||
|
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||||
|
AdminTrackingFlagRuleDto[] Flags,
|
||||||
|
string ManualReviewNotes);
|
||||||
|
|
||||||
|
public sealed record UpdateTrackingRulesRequest(
|
||||||
|
AdminTrackingSourceDto Source,
|
||||||
|
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||||
|
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||||
|
AdminTrackingFlagRuleDto[] Flags,
|
||||||
|
string ManualReviewNotes);
|
||||||
|
|
||||||
|
public sealed record UpdateTrackingSourceRequest(
|
||||||
|
AdminTrackingSourceDto Source);
|
||||||
|
|
||||||
|
public sealed record UpdateTrackingReviewNotesRequest(
|
||||||
|
string ManualReviewNotes,
|
||||||
|
bool ShowManualReviewNotesInReview);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed record AuthSessionDto(
|
|||||||
string DisplayName,
|
string DisplayName,
|
||||||
string Role,
|
string Role,
|
||||||
IEnumerable<string> PermissionKeys,
|
IEnumerable<string> PermissionKeys,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MustChangePassword = false,
|
bool MustChangePassword = false,
|
||||||
string? TeamLogin = null,
|
string? TeamLogin = null,
|
||||||
string? BoundTwitchUserId = null,
|
string? BoundTwitchUserId = null,
|
||||||
|
|||||||
@@ -36,16 +36,18 @@ public sealed record ShowactApplicationDto(
|
|||||||
string Status,
|
string Status,
|
||||||
string? ReviewNote,
|
string? ReviewNote,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset? ReviewedAt);
|
DateTimeOffset? ReviewedAt,
|
||||||
|
string FieldResponsesJson);
|
||||||
|
|
||||||
public sealed record CreateShowactApplicationRequest(
|
public sealed record CreateShowactApplicationRequest(
|
||||||
string ArtistName,
|
string? ArtistName = null,
|
||||||
string ContactEmail,
|
string? ContactEmail = null,
|
||||||
string ContactDiscord,
|
string? ContactDiscord = null,
|
||||||
string PlatformUrl,
|
string? PlatformUrl = null,
|
||||||
string PerformanceType,
|
string? PerformanceType = null,
|
||||||
string Description,
|
string? Description = null,
|
||||||
string TechnicalNotes,
|
string? TechnicalNotes = null,
|
||||||
string ReferenceUrl);
|
string? ReferenceUrl = null,
|
||||||
|
string? FieldResponsesJson = null);
|
||||||
|
|
||||||
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
|
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ public sealed record PublicSiteContentDto(
|
|||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
string PrivacyEmail,
|
string PrivacyEmail,
|
||||||
string PrivacyPolicyContent,
|
string PrivacyPolicyContent,
|
||||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||||
@@ -66,8 +68,11 @@ public sealed record PublicFeatureFlagsDto(
|
|||||||
bool ClipReviewEnabled,
|
bool ClipReviewEnabled,
|
||||||
string ClipSubmissionDisabledMessage,
|
string ClipSubmissionDisabledMessage,
|
||||||
bool ShowactApplicationsEnabled,
|
bool ShowactApplicationsEnabled,
|
||||||
|
DateOnly? ShowactApplicationStartsAt,
|
||||||
|
DateOnly? ShowactApplicationEndsAt,
|
||||||
string ShowactApplicationDisabledMessage,
|
string ShowactApplicationDisabledMessage,
|
||||||
bool SponsorsVisible);
|
bool SponsorsVisible,
|
||||||
|
string ShowactFormSchemaJson);
|
||||||
|
|
||||||
public sealed record OverviewResponse(
|
public sealed record OverviewResponse(
|
||||||
int SeasonId,
|
int SeasonId,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed record PublicCategoryDetailDto(
|
|||||||
string GroupName,
|
string GroupName,
|
||||||
string Description,
|
string Description,
|
||||||
int MaxNomineesPerUser,
|
int MaxNomineesPerUser,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax,
|
||||||
IEnumerable<CandidateSummaryDto> Candidates);
|
IEnumerable<CandidateSummaryDto> Candidates);
|
||||||
|
|
||||||
public sealed record SeasonCategoriesResponse(
|
public sealed record SeasonCategoriesResponse(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
namespace Backend.Contracts;
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
public sealed record UserNominationStateDto(
|
public sealed record UserNominationStateDto(
|
||||||
int CategoryId,
|
int? CategoryId,
|
||||||
|
string CategoryGroupName,
|
||||||
string[] Nominees);
|
string[] Nominees);
|
||||||
|
|
||||||
public sealed record UserVoteStateDto(
|
public sealed record UserVoteStateDto(
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ public sealed record NominationEntryRequest(
|
|||||||
|
|
||||||
public sealed record CreateNominationRequest(
|
public sealed record CreateNominationRequest(
|
||||||
int Year,
|
int Year,
|
||||||
int CategoryId,
|
int? CategoryId,
|
||||||
|
string? CategoryGroupName,
|
||||||
string TwitchUserId,
|
string TwitchUserId,
|
||||||
string[]? Nominees,
|
string[]? Nominees,
|
||||||
NominationEntryRequest[]? Nominations);
|
NominationEntryRequest[]? Nominations);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
public DbSet<Season> Seasons => Set<Season>();
|
public DbSet<Season> Seasons => Set<Season>();
|
||||||
public DbSet<Category> Categories => Set<Category>();
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
public DbSet<Candidate> Candidates => Set<Candidate>();
|
public DbSet<Candidate> Candidates => Set<Candidate>();
|
||||||
|
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
|
||||||
public DbSet<AwardResult> Results => Set<AwardResult>();
|
public DbSet<AwardResult> Results => Set<AwardResult>();
|
||||||
public DbSet<Nomination> Nominations => Set<Nomination>();
|
public DbSet<Nomination> Nominations => Set<Nomination>();
|
||||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||||
@@ -30,6 +31,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.Name).HasMaxLength(160);
|
entity.Property(item => item.Name).HasMaxLength(160);
|
||||||
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
|
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
|
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
|
||||||
|
entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SiteSettings>(entity =>
|
modelBuilder.Entity<SiteSettings>(entity =>
|
||||||
@@ -53,14 +56,19 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
||||||
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
||||||
entity.Property(item => item.TwitchScope).HasMaxLength(300);
|
entity.Property(item => item.TwitchScope).HasMaxLength(300);
|
||||||
|
entity.Property(item => item.SessionIdleTimeoutHours).HasDefaultValue(3);
|
||||||
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
||||||
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
||||||
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.TrackingRulesJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.ViewerStatsProviderBaseUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
|
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
|
||||||
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
|
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
|
||||||
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
|
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
|
||||||
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
|
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
|
||||||
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
|
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
|
||||||
|
entity.Property(item => item.ShowactApplicationStartsAt);
|
||||||
|
entity.Property(item => item.ShowactApplicationEndsAt);
|
||||||
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
|
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
|
||||||
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
|
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
|
||||||
});
|
});
|
||||||
@@ -93,13 +101,17 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.GroupName).HasMaxLength(80);
|
entity.Property(item => item.GroupName).HasMaxLength(80);
|
||||||
entity.Property(item => item.Name).HasMaxLength(120);
|
entity.Property(item => item.Name).HasMaxLength(120);
|
||||||
entity.Property(item => item.Description).HasMaxLength(400);
|
entity.Property(item => item.Description).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.ViewerRangeMin);
|
||||||
|
entity.Property(item => item.ViewerRangeMax);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Candidate>(entity =>
|
modelBuilder.Entity<Candidate>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.HasIndex(item => item.StreamerIdentityId);
|
||||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||||
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
||||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||||
|
entity.Property(item => item.NominationTally).HasDefaultValue(0);
|
||||||
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
|
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
|
||||||
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
|
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
|
||||||
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
|
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
|
||||||
@@ -108,15 +120,47 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
|
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<StreamerIdentity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(item => item.NormalizedKey).IsUnique();
|
||||||
|
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||||
|
entity.Property(item => item.Login).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.NormalizedKey).HasMaxLength(180);
|
||||||
|
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.ProfileUrl).HasMaxLength(500);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Nomination>(entity =>
|
modelBuilder.Entity<Nomination>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.Property(item => item.CategoryGroupName).HasMaxLength(80).HasDefaultValue(string.Empty);
|
||||||
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
||||||
entity.Property(item => item.CandidateText).HasMaxLength(120);
|
entity.Property(item => item.CandidateText).HasMaxLength(120);
|
||||||
entity.Property(item => item.StreamUrl).HasMaxLength(300);
|
entity.Property(item => item.StreamUrl).HasMaxLength(300);
|
||||||
|
entity.Property(item => item.ResolvedChannel).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.ResolvedPlatform).HasMaxLength(40);
|
||||||
|
entity.Property(item => item.HoursStreamed);
|
||||||
|
entity.Property(item => item.HoursWatched);
|
||||||
|
entity.Property(item => item.PeakViewers);
|
||||||
|
entity.Property(item => item.FollowersGained);
|
||||||
|
entity.Property(item => item.TrackerStatus).HasMaxLength(40).HasDefaultValue("pending");
|
||||||
|
entity.Property(item => item.TrackingReviewStatus).HasMaxLength(30).HasDefaultValue("clear");
|
||||||
|
entity.Property(item => item.TrackingFlagsJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.TrackingReviewNote).HasMaxLength(1000);
|
||||||
|
entity.Property(item => item.TrackingReviewedByTwitchId).HasMaxLength(120);
|
||||||
entity.Property(item => item.Status).HasMaxLength(20);
|
entity.Property(item => item.Status).HasMaxLength(20);
|
||||||
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||||
|
entity.HasIndex(item => new { item.SeasonId, item.CategoryGroupName, item.Status });
|
||||||
|
entity.HasIndex(item => new { item.SeasonId, item.StreamerIdentityId, item.CategoryGroupName });
|
||||||
|
entity.HasOne(item => item.Category)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(item => item.SuggestedCategory)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.SuggestedCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<VoteBallot>(entity =>
|
modelBuilder.Entity<VoteBallot>(entity =>
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
|
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackingRulesJson" text NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ViewerStatsProviderBaseUrl" character varying(400) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackingReviewNotes" text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
|
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
@@ -34,12 +43,30 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ShowactApplicationStartsAt" date NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ShowactApplicationEndsAt" date NULL;
|
||||||
|
|
||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
|
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
|
||||||
|
|
||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true;
|
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "HoursStreamed" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "HoursWatched" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "PeakViewers" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "FollowersGained" integer NULL;
|
||||||
|
|
||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
@@ -55,6 +82,9 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT '';
|
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "SessionIdleTimeoutHours" integer NOT NULL DEFAULT 3;
|
||||||
|
|
||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '';
|
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
@@ -70,6 +100,9 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
|
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE "Seasons"
|
||||||
|
ADD COLUMN IF NOT EXISTS "SubcategoryTemplatesJson" text NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
"SeasonId" integer NULL,
|
"SeasonId" integer NULL,
|
||||||
@@ -254,6 +287,95 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "Candidates"
|
ALTER TABLE "Candidates"
|
||||||
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked';
|
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked';
|
||||||
|
|
||||||
|
ALTER TABLE "Categories"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ViewerRangeMin" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Categories"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ViewerRangeMax" integer NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "StreamerIdentities" (
|
||||||
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"Platform" character varying(40) NOT NULL,
|
||||||
|
"Login" character varying(120) NOT NULL,
|
||||||
|
"NormalizedKey" character varying(180) NOT NULL,
|
||||||
|
"DisplayName" character varying(120) NOT NULL,
|
||||||
|
"ProfileUrl" character varying(500) NULL,
|
||||||
|
"LastResolvedAt" timestamp with time zone NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IX_StreamerIdentities_NormalizedKey"
|
||||||
|
ON "StreamerIdentities" ("NormalizedKey");
|
||||||
|
|
||||||
|
ALTER TABLE "Candidates"
|
||||||
|
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Candidates"
|
||||||
|
ADD COLUMN IF NOT EXISTS "NominationTally" integer NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_Candidates_StreamerIdentityId"
|
||||||
|
ON "Candidates" ("StreamerIdentityId");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'FK_Candidates_StreamerIdentities_StreamerIdentityId'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "Candidates"
|
||||||
|
ADD CONSTRAINT "FK_Candidates_StreamerIdentities_StreamerIdentityId"
|
||||||
|
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ALTER COLUMN "CategoryId" DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "CategoryGroupName" character varying(80) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE "Nominations" n
|
||||||
|
SET "CategoryGroupName" = c."GroupName"
|
||||||
|
FROM "Categories" c
|
||||||
|
WHERE n."CategoryId" = c."Id"
|
||||||
|
AND COALESCE(n."CategoryGroupName", '') = '';
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "SuggestedCategoryId" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ResolvedChannel" character varying(120) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ResolvedPlatform" character varying(40) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "AvgViewers" integer NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackerStatus" character varying(40) NOT NULL DEFAULT 'pending';
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackerCheckedAt" timestamp with time zone NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackingReviewStatus" character varying(30) NOT NULL DEFAULT 'clear';
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackingFlagsJson" text NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackingReviewNote" character varying(1000) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackingReviewedByTwitchId" character varying(120) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "TrackingReviewedAt" timestamp with time zone NULL;
|
||||||
|
|
||||||
ALTER TABLE "Nominations"
|
ALTER TABLE "Nominations"
|
||||||
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
|
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
|
||||||
|
|
||||||
@@ -269,6 +391,36 @@ public static class OperationalTablesBootstrapper
|
|||||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
|
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
|
||||||
ON "Nominations" ("SeasonId", "Status");
|
ON "Nominations" ("SeasonId", "Status");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_CategoryGroupName_Status"
|
||||||
|
ON "Nominations" ("SeasonId", "CategoryGroupName", "Status");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName"
|
||||||
|
ON "Nominations" ("SeasonId", "StreamerIdentityId", "CategoryGroupName");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'FK_Nominations_StreamerIdentities_StreamerIdentityId'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD CONSTRAINT "FK_Nominations_StreamerIdentities_StreamerIdentityId"
|
||||||
|
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'FK_Nominations_Categories_SuggestedCategoryId'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "Nominations"
|
||||||
|
ADD CONSTRAINT "FK_Nominations_Categories_SuggestedCategoryId"
|
||||||
|
FOREIGN KEY ("SuggestedCategoryId") REFERENCES "Categories" ("Id")
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "TeamMembers" (
|
CREATE TABLE IF NOT EXISTS "TeamMembers" (
|
||||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
"Login" character varying(80) NOT NULL,
|
"Login" character varying(80) NOT NULL,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Backend.Domain;
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Data;
|
namespace Backend.Data;
|
||||||
@@ -7,54 +8,137 @@ public static partial class SeedDataBootstrapper
|
|||||||
{
|
{
|
||||||
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
|
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
|
||||||
{
|
{
|
||||||
var seasonCategories = await db.Categories
|
var categories = await db.Categories
|
||||||
.Where(item => item.SeasonId == season.Id)
|
.Where(item => item.SeasonId == season.Id)
|
||||||
.ToArrayAsync();
|
.ToListAsync();
|
||||||
|
var templates = SeedCatalog.DefaultSubcategoryTemplates
|
||||||
|
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||||
|
.ToArray();
|
||||||
|
var usedCategories = new HashSet<Category>();
|
||||||
|
|
||||||
foreach (var category in seasonCategories)
|
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||||
|
|
||||||
|
var nextSortOrder = 1;
|
||||||
|
foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder))
|
||||||
{
|
{
|
||||||
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
|
foreach (var template in templates)
|
||||||
{
|
{
|
||||||
continue;
|
var category = FindReusableCategory(categories, award, template, usedCategories)
|
||||||
}
|
?? new Category { SeasonId = season.Id };
|
||||||
|
usedCategories.Add(category);
|
||||||
|
|
||||||
var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug);
|
category.GroupName = award.Name;
|
||||||
category.GroupName = target.GroupName;
|
category.Name = template.Name;
|
||||||
category.Name = target.Name;
|
category.Slug = BuildCategorySlug(award.Slug, template.Slug);
|
||||||
category.Slug = target.Slug;
|
category.Description = award.Description;
|
||||||
category.Description = target.Description;
|
category.SortOrder = nextSortOrder++;
|
||||||
category.SortOrder = target.SortOrder;
|
category.MaxNomineesPerUser = 3;
|
||||||
category.MaxNomineesPerUser = 3;
|
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||||
|
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||||
|
|
||||||
|
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||||
|
{
|
||||||
|
db.Categories.Add(category);
|
||||||
|
categories.Add(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var staleCategories = categories
|
||||||
|
.Where(item => item.Id > 0 && !usedCategories.Contains(item))
|
||||||
|
.ToArray();
|
||||||
|
await RemoveStaleCategoryDataAsync(db, staleCategories);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
var existing = await db.Categories
|
private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
|
||||||
.Where(item => item.SeasonId == season.Id)
|
{
|
||||||
.Select(item => item.Slug)
|
if (staleCategories.Length == 0)
|
||||||
.ToArrayAsync();
|
|
||||||
var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var seed in SeedCatalog.CategorySeeds)
|
|
||||||
{
|
{
|
||||||
if (existingSlugs.Contains(seed.Slug))
|
return;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Categories.Add(new Category
|
|
||||||
{
|
|
||||||
SeasonId = season.Id,
|
|
||||||
GroupName = seed.GroupName,
|
|
||||||
Name = seed.Name,
|
|
||||||
Slug = seed.Slug,
|
|
||||||
Description = seed.Description,
|
|
||||||
SortOrder = seed.SortOrder,
|
|
||||||
MaxNomineesPerUser = 3,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
|
||||||
|
var staleCandidateIds = await db.Candidates
|
||||||
|
.Where(item => staleCategoryIds.Contains(item.CategoryId))
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var staleVoteEntries = await db.VoteEntries
|
||||||
|
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
|
||||||
|
.ToArrayAsync();
|
||||||
|
db.VoteEntries.RemoveRange(staleVoteEntries);
|
||||||
|
|
||||||
|
var staleResults = await db.Results
|
||||||
|
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
|
||||||
|
.ToArrayAsync();
|
||||||
|
db.Results.RemoveRange(staleResults);
|
||||||
|
|
||||||
|
var affectedClips = await db.ClipSubmissions
|
||||||
|
.Where(item =>
|
||||||
|
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||||
|
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
|
||||||
|
.ToArrayAsync();
|
||||||
|
foreach (var clip in affectedClips)
|
||||||
|
{
|
||||||
|
if (clip.CategoryId != null && staleCategoryIds.Contains(clip.CategoryId.Value))
|
||||||
|
{
|
||||||
|
clip.CategoryId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clip.CandidateId != null && staleCandidateIds.Contains(clip.CandidateId.Value))
|
||||||
|
{
|
||||||
|
clip.CandidateId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var affectedNominations = await db.Nominations
|
||||||
|
.Where(item =>
|
||||||
|
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||||
|
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value))
|
||||||
|
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
|
||||||
|
.ToArrayAsync();
|
||||||
|
foreach (var nomination in affectedNominations)
|
||||||
|
{
|
||||||
|
if (nomination.CategoryId != null && staleCategoryIds.Contains(nomination.CategoryId.Value))
|
||||||
|
{
|
||||||
|
nomination.CategoryId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nomination.SuggestedCategoryId != null && staleCategoryIds.Contains(nomination.SuggestedCategoryId.Value))
|
||||||
|
{
|
||||||
|
nomination.SuggestedCategoryId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nomination.CandidateId != null && staleCandidateIds.Contains(nomination.CandidateId.Value))
|
||||||
|
{
|
||||||
|
nomination.CandidateId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleCandidates = await db.Candidates
|
||||||
|
.Where(item => staleCandidateIds.Contains(item.Id))
|
||||||
|
.ToArrayAsync();
|
||||||
|
db.Candidates.RemoveRange(staleCandidates);
|
||||||
|
db.Categories.RemoveRange(staleCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Category? FindReusableCategory(
|
||||||
|
List<Category> categories,
|
||||||
|
AwardCategorySeed award,
|
||||||
|
SeasonSubcategoryTemplateSetting template,
|
||||||
|
HashSet<Category> usedCategories)
|
||||||
|
{
|
||||||
|
var targetSlug = BuildCategorySlug(award.Slug, template.Slug);
|
||||||
|
|
||||||
|
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||||
|
&& string.Equals(item.Slug, targetSlug, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||||
|
&& string.Equals(item.GroupName, award.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||||
|
&& string.Equals(item.GroupName, award.LegacyGroupName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
|
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
|
||||||
@@ -142,4 +226,7 @@ public static partial class SeedDataBootstrapper
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildCategorySlug(string awardSlug, string templateSlug) =>
|
||||||
|
$"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}";
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-55
@@ -1,6 +1,8 @@
|
|||||||
namespace Backend.Data;
|
namespace Backend.Data;
|
||||||
|
|
||||||
internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder);
|
using Backend.Services;
|
||||||
|
|
||||||
|
internal sealed record AwardCategorySeed(string LegacyGroupName, string Name, string Slug, string Description, int SortOrder);
|
||||||
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||||
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||||
internal sealed record SiteFaqSeed(string Question, string Answer);
|
internal sealed record SiteFaqSeed(string Question, string Answer);
|
||||||
@@ -8,10 +10,17 @@ internal sealed record SiteSocialSeed(string Label, string Platform, string Url,
|
|||||||
|
|
||||||
internal static class SeedCatalog
|
internal static class SeedCatalog
|
||||||
{
|
{
|
||||||
internal static readonly CategorySeed[] CategorySeeds =
|
internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates =
|
||||||
[
|
[
|
||||||
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1),
|
new("Hidden Star", "hidden-star", 1, 1, 20),
|
||||||
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2),
|
new("Rising Star", "rising-star", 2, 21, 60),
|
||||||
|
new("Shining Star", "shining-star", 3, 61, null),
|
||||||
|
];
|
||||||
|
|
||||||
|
internal static readonly AwardCategorySeed[] AwardCategorySeeds =
|
||||||
|
[
|
||||||
|
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die größte Auszeichnung des Jahres.", 1),
|
||||||
|
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie für die Szene.", 2),
|
||||||
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
|
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
|
||||||
new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
|
new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
|
||||||
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
|
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
|
||||||
@@ -34,16 +43,16 @@ internal static class SeedCatalog
|
|||||||
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
|
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
|
||||||
new(
|
new(
|
||||||
"Wie funktioniert das Voting?",
|
"Wie funktioniert das Voting?",
|
||||||
"Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."),
|
"Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase."),
|
||||||
new(
|
new(
|
||||||
"Was kostet die Teilnahme?",
|
"Was kostet die Teilnahme?",
|
||||||
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."),
|
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans."),
|
||||||
new(
|
new(
|
||||||
"Wann und wo findet die Award-Show statt?",
|
"Wann und wo findet die Award-Show statt?",
|
||||||
"Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"),
|
"Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!"),
|
||||||
new(
|
new(
|
||||||
"Ich wurde nominiert — was nun?",
|
"Ich wurde nominiert — was nun?",
|
||||||
"Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."),
|
"Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt."),
|
||||||
];
|
];
|
||||||
|
|
||||||
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
|
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
|
||||||
@@ -60,18 +69,18 @@ Anbieter
|
|||||||
VTuber Star Awards, vertreten durch Jayuhime.
|
VTuber Star Awards, vertreten durch Jayuhime.
|
||||||
|
|
||||||
Kontakt
|
Kontakt
|
||||||
Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
Nutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||||
|
|
||||||
Hinweis
|
Hinweis
|
||||||
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.
|
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
internal const string DefaultContactContent = """
|
internal const string DefaultContactContent = """
|
||||||
Kontakt zum Award-Team
|
Kontakt zum Award-Team
|
||||||
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.
|
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.
|
||||||
|
|
||||||
Datenschutzfragen
|
Datenschutzfragen
|
||||||
Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
Für Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||||
|
|
||||||
Community & Kooperationen
|
Community & Kooperationen
|
||||||
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||||
@@ -79,57 +88,57 @@ Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
|||||||
|
|
||||||
internal const string DefaultSponsorsContent = """
|
internal const string DefaultSponsorsContent = """
|
||||||
Sponsoren & Partner
|
Sponsoren & Partner
|
||||||
Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
Hier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
||||||
|
|
||||||
Partner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.
|
Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
||||||
[
|
[
|
||||||
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||||
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"),
|
new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"),
|
new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||||
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||||
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||||
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||||
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||||
new("best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"),
|
new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||||
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||||
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||||
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
new("community-liebling", "Lumi", "@lumi_vt", "Cake"),
|
new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||||
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"),
|
new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||||
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||||
];
|
];
|
||||||
|
|
||||||
internal static readonly WinnerSeed[] WinnerSeeds =
|
internal static readonly WinnerSeed[] WinnerSeeds =
|
||||||
[
|
[
|
||||||
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||||
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||||
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||||
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||||
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"),
|
new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"),
|
||||||
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"),
|
new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"),
|
new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||||
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||||
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||||
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"),
|
new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"),
|
||||||
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||||
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"),
|
new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"),
|
||||||
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||||
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"),
|
new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"),
|
||||||
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||||
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"),
|
new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||||
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"),
|
new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||||
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"),
|
new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"),
|
||||||
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"),
|
new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"),
|
||||||
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"),
|
new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
|
||||||
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"),
|
new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,15 +49,23 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
|||||||
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||||
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
|
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
|
||||||
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
|
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
|
||||||
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
|
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.",
|
||||||
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
|
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
|
||||||
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults),
|
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults),
|
||||||
|
TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
|
||||||
|
TrackingRulesSettings.DefaultSource,
|
||||||
|
TrackingRulesSettings.DefaultImportantMetrics,
|
||||||
|
TrackingRulesSettings.DefaultOptionalMetrics,
|
||||||
|
TrackingRulesSettings.DefaultFlags)),
|
||||||
|
ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl,
|
||||||
|
TrackingReviewNotes = "Fallback-Quellen für manuelle Reviews:\\n- SullyGnome\\n- Twitch-Kanal direkt\\n\\nNutze diese Notizen für Edge Cases und manuelle Tier-Entscheidungen.",
|
||||||
NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
|
NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
|
||||||
ClipSubmissionsEnabled = false,
|
ClipSubmissionsEnabled = false,
|
||||||
ClipReviewEnabled = true,
|
ClipReviewEnabled = true,
|
||||||
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
|
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
|
||||||
ShowactApplicationsEnabled = false,
|
ShowactApplicationsEnabled = false,
|
||||||
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
|
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
|
||||||
|
SessionIdleTimeoutHours = 3,
|
||||||
SponsorsVisible = true,
|
SponsorsVisible = true,
|
||||||
SocialLinksJson = JsonSerializer.Serialize(new[]
|
SocialLinksJson = JsonSerializer.Serialize(new[]
|
||||||
{
|
{
|
||||||
@@ -152,7 +160,7 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
|||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Category>().HasData(
|
modelBuilder.Entity<Category>().HasData(
|
||||||
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die groesste Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die größte Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||||
new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 },
|
new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 },
|
||||||
new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 },
|
new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 },
|
||||||
new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 },
|
new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public static partial class SeedDataBootstrapper
|
|||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
|
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
|
||||||
|
await EnsureSeedReviewNominationsAsync(db, season, categories, candidates);
|
||||||
|
|
||||||
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
|
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
|
||||||
{
|
{
|
||||||
@@ -23,15 +24,15 @@ public static partial class SeedDataBootstrapper
|
|||||||
new ClipSubmission
|
new ClipSubmission
|
||||||
{
|
{
|
||||||
SeasonId = season.Id,
|
SeasonId = season.Id,
|
||||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
||||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"),
|
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"),
|
||||||
SubmittedByTwitchId = "local_user_3",
|
SubmittedByTwitchId = "local_user_3",
|
||||||
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
|
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
|
||||||
Title = "Starlight Debut Moment",
|
Title = "Starlight Debut Moment",
|
||||||
Creator = "Hoshimi Miyu",
|
Creator = "Hoshimi Miyu",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
Status = "approved",
|
Status = "approved",
|
||||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
||||||
ReviewedByTwitchId = "jayuhime_admin",
|
ReviewedByTwitchId = "jayuhime_admin",
|
||||||
CreatedFromIp = "127.0.0.1",
|
CreatedFromIp = "127.0.0.1",
|
||||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
|
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
|
||||||
@@ -40,15 +41,15 @@ public static partial class SeedDataBootstrapper
|
|||||||
new ClipSubmission
|
new ClipSubmission
|
||||||
{
|
{
|
||||||
SeasonId = season.Id,
|
SeasonId = season.Id,
|
||||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
||||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"),
|
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"),
|
||||||
SubmittedByTwitchId = "local_user_4",
|
SubmittedByTwitchId = "local_user_4",
|
||||||
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
|
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
|
||||||
Title = "Finale-Hype mit Chat-Chaos",
|
Title = "Finale-Hype mit Chat-Chaos",
|
||||||
Creator = "Kurainu",
|
Creator = "Kurainu",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
Status = "approved",
|
Status = "approved",
|
||||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
||||||
ReviewedByTwitchId = "jayuhime_admin",
|
ReviewedByTwitchId = "jayuhime_admin",
|
||||||
CreatedFromIp = "127.0.0.1",
|
CreatedFromIp = "127.0.0.1",
|
||||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
|
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
|
||||||
@@ -57,8 +58,8 @@ public static partial class SeedDataBootstrapper
|
|||||||
new ClipSubmission
|
new ClipSubmission
|
||||||
{
|
{
|
||||||
SeasonId = season.Id,
|
SeasonId = season.Id,
|
||||||
CategoryId = ResolveCategoryId(categories, "best-gaming"),
|
CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"),
|
||||||
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"),
|
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"),
|
||||||
SubmittedByTwitchId = "local_user",
|
SubmittedByTwitchId = "local_user",
|
||||||
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
|
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
|
||||||
Title = "Epischer Clutch im Finale",
|
Title = "Epischer Clutch im Finale",
|
||||||
@@ -71,15 +72,15 @@ public static partial class SeedDataBootstrapper
|
|||||||
new ClipSubmission
|
new ClipSubmission
|
||||||
{
|
{
|
||||||
SeasonId = season.Id,
|
SeasonId = season.Id,
|
||||||
CategoryId = ResolveCategoryId(categories, "gesang-musik"),
|
CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"),
|
||||||
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"),
|
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"),
|
||||||
SubmittedByTwitchId = "local_user_2",
|
SubmittedByTwitchId = "local_user_2",
|
||||||
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
|
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
|
||||||
Title = "Live-Cover mit Gänsehaut",
|
Title = "Live-Cover mit Gänsehaut",
|
||||||
Creator = "Melo Diva",
|
Creator = "Melo Diva",
|
||||||
Platform = "YouTube",
|
Platform = "YouTube",
|
||||||
Status = "approved",
|
Status = "approved",
|
||||||
ReviewNote = "Geprüfter Clip fuer Review-Workflow.",
|
ReviewNote = "Geprüfter Clip für Review-Workflow.",
|
||||||
ReviewedByTwitchId = "jayuhime_admin",
|
ReviewedByTwitchId = "jayuhime_admin",
|
||||||
CreatedFromIp = "127.0.0.1",
|
CreatedFromIp = "127.0.0.1",
|
||||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
|
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
|
||||||
@@ -114,7 +115,7 @@ public static partial class SeedDataBootstrapper
|
|||||||
EntityType = "database",
|
EntityType = "database",
|
||||||
EntityId = season.Year.ToString(),
|
EntityId = season.Year.ToString(),
|
||||||
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
|
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
|
||||||
MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }),
|
MetadataJson = JsonSerializer.Serialize(new { awardCategories = SeedCatalog.AwardCategorySeeds.Length, subcategories = SeedCatalog.DefaultSubcategoryTemplates.Length }),
|
||||||
CreatedFromIp = "seed",
|
CreatedFromIp = "seed",
|
||||||
UserAgent = "seed-bootstrap",
|
UserAgent = "seed-bootstrap",
|
||||||
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
|
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
|
||||||
@@ -279,5 +280,275 @@ public static partial class SeedDataBootstrapper
|
|||||||
.Where(char.IsLetterOrDigit)
|
.Where(char.IsLetterOrDigit)
|
||||||
.ToArray());
|
.ToArray());
|
||||||
|
|
||||||
|
private static async Task EnsureSeedReviewNominationsAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
Season season,
|
||||||
|
IReadOnlyDictionary<string, Category> categories,
|
||||||
|
Candidate[] candidates)
|
||||||
|
{
|
||||||
|
var staleSeedNominations = await db.Nominations
|
||||||
|
.Where(item =>
|
||||||
|
item.SeasonId == season.Id
|
||||||
|
&& (
|
||||||
|
item.SubmittedByTwitchId.StartsWith("seed_review_")
|
||||||
|
|| item.SubmittedByTwitchId == "twitch_hoshi"
|
||||||
|
|| item.SubmittedByTwitchId == "twitch_kurainu"
|
||||||
|
|| item.SubmittedByTwitchId.StartsWith("demo_user")
|
||||||
|
|| item.SubmittedByTwitchId.StartsWith("local_user")
|
||||||
|
))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
if (staleSeedNominations.Length > 0)
|
||||||
|
{
|
||||||
|
db.Nominations.RemoveRange(staleSeedNominations);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedCategories = categories.Values
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.ToArray();
|
||||||
|
var candidatesByCategoryId = candidates
|
||||||
|
.GroupBy(item => item.CategoryId)
|
||||||
|
.ToDictionary(
|
||||||
|
grouping => grouping.Key,
|
||||||
|
grouping => grouping.OrderBy(item => item.DisplayName).ToArray());
|
||||||
|
|
||||||
|
var seedNominations = new List<Nomination>();
|
||||||
|
var createdAt = new DateTimeOffset(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
foreach (var category in orderedCategories)
|
||||||
|
{
|
||||||
|
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
||||||
|
var existingCandidate = categoryCandidates?.FirstOrDefault();
|
||||||
|
|
||||||
|
seedNominations.AddRange(BuildPendingSeedGroup(
|
||||||
|
category,
|
||||||
|
existingCandidate,
|
||||||
|
groupKey: "existing",
|
||||||
|
firstSubmitter: $"seed_review_{category.Slug}_existing_a",
|
||||||
|
secondSubmitter: $"seed_review_{category.Slug}_existing_b",
|
||||||
|
createdAt,
|
||||||
|
useSuggestedCategory: true,
|
||||||
|
trackerStatus: "resolved"));
|
||||||
|
createdAt = createdAt.AddMinutes(8);
|
||||||
|
|
||||||
|
seedNominations.AddRange(BuildPendingSeedGroup(
|
||||||
|
category,
|
||||||
|
existingCandidate: null,
|
||||||
|
groupKey: "fresh",
|
||||||
|
firstSubmitter: $"seed_review_{category.Slug}_fresh_a",
|
||||||
|
secondSubmitter: $"seed_review_{category.Slug}_fresh_b",
|
||||||
|
createdAt,
|
||||||
|
useSuggestedCategory: false,
|
||||||
|
trackerStatus: "unsupported_platform"));
|
||||||
|
createdAt = createdAt.AddMinutes(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var category in orderedCategories.Take(2))
|
||||||
|
{
|
||||||
|
seedNominations.AddRange(BuildReviewedSeedGroup(
|
||||||
|
category,
|
||||||
|
status: "rejected",
|
||||||
|
displayName: $"{category.GroupName} Review Return",
|
||||||
|
submittedByPrefix: $"seed_review_{category.Slug}_rejected",
|
||||||
|
createdAt,
|
||||||
|
reviewedByTwitchId: "jayuhime_admin",
|
||||||
|
candidateId: null,
|
||||||
|
candidateDisplayName: null));
|
||||||
|
createdAt = createdAt.AddMinutes(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var category in orderedCategories.Skip(2).Take(2))
|
||||||
|
{
|
||||||
|
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
||||||
|
var candidate = categoryCandidates?.FirstOrDefault();
|
||||||
|
seedNominations.AddRange(BuildReviewedSeedGroup(
|
||||||
|
category,
|
||||||
|
status: "approved",
|
||||||
|
displayName: candidate?.DisplayName ?? $"{category.GroupName} Approved Pick",
|
||||||
|
submittedByPrefix: $"seed_review_{category.Slug}_approved",
|
||||||
|
createdAt,
|
||||||
|
reviewedByTwitchId: "jayuhime_admin",
|
||||||
|
candidateId: candidate?.Id,
|
||||||
|
candidateDisplayName: candidate?.DisplayName));
|
||||||
|
createdAt = createdAt.AddMinutes(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Nominations.AddRange(seedNominations);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Nomination> BuildPendingSeedGroup(
|
||||||
|
Category category,
|
||||||
|
Candidate? existingCandidate,
|
||||||
|
string groupKey,
|
||||||
|
string firstSubmitter,
|
||||||
|
string secondSubmitter,
|
||||||
|
DateTimeOffset createdAt,
|
||||||
|
bool useSuggestedCategory,
|
||||||
|
string trackerStatus)
|
||||||
|
{
|
||||||
|
var displayName = existingCandidate?.DisplayName ?? BuildFreshSeedName(category, groupKey);
|
||||||
|
var platform = existingCandidate?.Platform ?? "YouTube";
|
||||||
|
var channelSlug = existingCandidate?.ChannelSlug ?? BuildSeedChannelSlug(category, groupKey);
|
||||||
|
var streamUrl = BuildSeedStreamUrl(platform, channelSlug);
|
||||||
|
var avgViewers = ResolveSeedViewerValue(category);
|
||||||
|
int? suggestedCategoryId = useSuggestedCategory ? category.Id : null;
|
||||||
|
|
||||||
|
yield return new Nomination
|
||||||
|
{
|
||||||
|
SeasonId = category.SeasonId,
|
||||||
|
CategoryId = category.Id,
|
||||||
|
CategoryGroupName = category.GroupName,
|
||||||
|
SubmittedByTwitchId = firstSubmitter,
|
||||||
|
CandidateText = displayName,
|
||||||
|
StreamUrl = streamUrl,
|
||||||
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||||
|
ResolvedPlatform = platform,
|
||||||
|
AvgViewers = avgViewers,
|
||||||
|
SuggestedCategoryId = suggestedCategoryId,
|
||||||
|
TrackerStatus = trackerStatus,
|
||||||
|
TrackerCheckedAt = createdAt.AddMinutes(2),
|
||||||
|
TrackingReviewStatus = "clear",
|
||||||
|
Status = "pending",
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
yield return new Nomination
|
||||||
|
{
|
||||||
|
SeasonId = category.SeasonId,
|
||||||
|
CategoryId = category.Id,
|
||||||
|
CategoryGroupName = category.GroupName,
|
||||||
|
SubmittedByTwitchId = secondSubmitter,
|
||||||
|
CandidateText = displayName,
|
||||||
|
StreamUrl = streamUrl,
|
||||||
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||||
|
ResolvedPlatform = platform,
|
||||||
|
AvgViewers = avgViewers,
|
||||||
|
SuggestedCategoryId = suggestedCategoryId,
|
||||||
|
TrackerStatus = trackerStatus,
|
||||||
|
TrackerCheckedAt = createdAt.AddMinutes(3),
|
||||||
|
TrackingReviewStatus = "clear",
|
||||||
|
Status = "pending",
|
||||||
|
CreatedAt = createdAt.AddMinutes(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Nomination> BuildReviewedSeedGroup(
|
||||||
|
Category category,
|
||||||
|
string status,
|
||||||
|
string displayName,
|
||||||
|
string submittedByPrefix,
|
||||||
|
DateTimeOffset createdAt,
|
||||||
|
string reviewedByTwitchId,
|
||||||
|
int? candidateId,
|
||||||
|
string? candidateDisplayName)
|
||||||
|
{
|
||||||
|
var channelSlug = BuildSeedChannelSlug(category, $"{status}_{displayName}");
|
||||||
|
var streamUrl = BuildSeedStreamUrl("Twitch", channelSlug);
|
||||||
|
var reviewNote = status == "approved"
|
||||||
|
? "Seed-Datensatz: bereits als Kandidat übernommen."
|
||||||
|
: "Seed-Datensatz: bewusst verworfen für Undo-Tests.";
|
||||||
|
|
||||||
|
yield return new Nomination
|
||||||
|
{
|
||||||
|
SeasonId = category.SeasonId,
|
||||||
|
CategoryId = category.Id,
|
||||||
|
CategoryGroupName = category.GroupName,
|
||||||
|
SubmittedByTwitchId = $"{submittedByPrefix}_a",
|
||||||
|
CandidateId = candidateId,
|
||||||
|
CandidateText = displayName,
|
||||||
|
StreamUrl = streamUrl,
|
||||||
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||||
|
ResolvedPlatform = "Twitch",
|
||||||
|
AvgViewers = ResolveSeedViewerValue(category),
|
||||||
|
SuggestedCategoryId = category.Id,
|
||||||
|
TrackerStatus = "resolved",
|
||||||
|
TrackerCheckedAt = createdAt.AddMinutes(2),
|
||||||
|
TrackingReviewStatus = "reviewed",
|
||||||
|
TrackingReviewNote = reviewNote,
|
||||||
|
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
||||||
|
TrackingReviewedAt = createdAt.AddMinutes(4),
|
||||||
|
Status = status,
|
||||||
|
ReviewNote = reviewNote,
|
||||||
|
ReviewedByTwitchId = reviewedByTwitchId,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
ReviewedAt = createdAt.AddMinutes(4),
|
||||||
|
};
|
||||||
|
|
||||||
|
yield return new Nomination
|
||||||
|
{
|
||||||
|
SeasonId = category.SeasonId,
|
||||||
|
CategoryId = category.Id,
|
||||||
|
CategoryGroupName = category.GroupName,
|
||||||
|
SubmittedByTwitchId = $"{submittedByPrefix}_b",
|
||||||
|
CandidateId = candidateId,
|
||||||
|
CandidateText = displayName,
|
||||||
|
StreamUrl = streamUrl,
|
||||||
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
||||||
|
ResolvedPlatform = "Twitch",
|
||||||
|
AvgViewers = ResolveSeedViewerValue(category),
|
||||||
|
SuggestedCategoryId = category.Id,
|
||||||
|
TrackerStatus = "resolved",
|
||||||
|
TrackerCheckedAt = createdAt.AddMinutes(3),
|
||||||
|
TrackingReviewStatus = "reviewed",
|
||||||
|
TrackingReviewNote = reviewNote,
|
||||||
|
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
||||||
|
TrackingReviewedAt = createdAt.AddMinutes(5),
|
||||||
|
Status = status,
|
||||||
|
ReviewNote = reviewNote,
|
||||||
|
ReviewedByTwitchId = reviewedByTwitchId,
|
||||||
|
CreatedAt = createdAt.AddMinutes(1),
|
||||||
|
ReviewedAt = createdAt.AddMinutes(5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFreshSeedName(Category category, string groupKey) =>
|
||||||
|
groupKey switch
|
||||||
|
{
|
||||||
|
"fresh" => $"{category.GroupName} Spotlight {category.Name}",
|
||||||
|
_ => $"{category.GroupName} {category.Name} Pick",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string BuildSeedChannelSlug(Category category, string suffix)
|
||||||
|
{
|
||||||
|
var raw = $"{category.Slug}-{suffix}"
|
||||||
|
.Trim()
|
||||||
|
.TrimStart('@')
|
||||||
|
.ToLowerInvariant();
|
||||||
|
|
||||||
|
return new string(raw.Where(char.IsLetterOrDigit).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSeedStreamUrl(string platform, string channelSlug) =>
|
||||||
|
platform.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"youtube" => $"https://www.youtube.com/@{channelSlug}",
|
||||||
|
"kick" => $"https://kick.com/{channelSlug}",
|
||||||
|
"cake" => $"https://cake.gg/{channelSlug}",
|
||||||
|
_ => $"https://www.twitch.tv/{channelSlug}",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int ResolveSeedViewerValue(Category category)
|
||||||
|
{
|
||||||
|
if (category.ViewerRangeMin is int min && category.ViewerRangeMax is int max)
|
||||||
|
{
|
||||||
|
return min + ((max - min) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.ViewerRangeMin is int lowerBound)
|
||||||
|
{
|
||||||
|
return lowerBound + 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.ViewerRangeMax is int upperBound)
|
||||||
|
{
|
||||||
|
return Math.Max(1, upperBound - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
|
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,34 @@ public static partial class SeedDataBootstrapper
|
|||||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults);
|
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!HasValidTrackingRules(settings.TrackingRulesJson))
|
||||||
|
{
|
||||||
|
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
|
||||||
|
TrackingRulesSettings.DefaultSource,
|
||||||
|
TrackingRulesSettings.DefaultImportantMetrics,
|
||||||
|
TrackingRulesSettings.DefaultOptionalMetrics,
|
||||||
|
TrackingRulesSettings.DefaultFlags));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.ViewerStatsProviderBaseUrl))
|
||||||
|
{
|
||||||
|
settings.ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.TrackingReviewNotes))
|
||||||
|
{
|
||||||
|
settings.TrackingReviewNotes = """
|
||||||
|
Fallback-Quellen für manuelle Reviews:
|
||||||
|
- SullyGnome Channel Summary
|
||||||
|
- Offizieller Twitch-Kanal
|
||||||
|
|
||||||
|
Prüfe bei Edge Cases:
|
||||||
|
- passt der Kanal wirklich zur Unterkategorie?
|
||||||
|
- fehlen TwitchTracker-Daten nur temporär?
|
||||||
|
- braucht der Fall eine manuelle Team-Notiz?
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
|
if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
|
||||||
{
|
{
|
||||||
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
|
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
|
||||||
@@ -83,7 +111,12 @@ public static partial class SeedDataBootstrapper
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
|
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
|
||||||
{
|
{
|
||||||
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.";
|
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.SessionIdleTimeoutHours < 3)
|
||||||
|
{
|
||||||
|
settings.SessionIdleTimeoutHours = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +154,14 @@ public static partial class SeedDataBootstrapper
|
|||||||
private static bool HasValidWorkflowRules(string? json) =>
|
private static bool HasValidWorkflowRules(string? json) =>
|
||||||
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length;
|
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length;
|
||||||
|
|
||||||
|
private static bool HasValidTrackingRules(string? json)
|
||||||
|
{
|
||||||
|
var rules = TrackingRulesSettings.Read(new Backend.Domain.SiteSettings { TrackingRulesJson = json ?? string.Empty });
|
||||||
|
return rules.ImportantMetrics.Length == TrackingRulesSettings.DefaultImportantMetrics.Length
|
||||||
|
&& rules.OptionalMetrics.Length == TrackingRulesSettings.DefaultOptionalMetrics.Length
|
||||||
|
&& rules.Flags.Length == TrackingRulesSettings.DefaultFlags.Length;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool HasValidNominationLinkBlacklist(string? json)
|
private static bool HasValidNominationLinkBlacklist(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ public sealed class Candidate
|
|||||||
public Season Season { get; set; } = null!;
|
public Season Season { get; set; } = null!;
|
||||||
public int CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public Category Category { get; set; } = null!;
|
public Category Category { get; set; } = null!;
|
||||||
|
public int? StreamerIdentityId { get; set; }
|
||||||
|
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||||
public string DisplayName { get; set; } = string.Empty;
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
public string ChannelSlug { get; set; } = string.Empty;
|
public string ChannelSlug { get; set; } = string.Empty;
|
||||||
public string Platform { get; set; } = "Twitch";
|
public string Platform { get; set; } = "Twitch";
|
||||||
|
public int NominationTally { get; set; }
|
||||||
public string AcceptanceStatus { get; set; } = "open";
|
public string AcceptanceStatus { get; set; } = "open";
|
||||||
public string? AcceptanceNote { get; set; }
|
public string? AcceptanceNote { get; set; }
|
||||||
public string? ClipCompilationUrl { get; set; }
|
public string? ClipCompilationUrl { get; set; }
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ public sealed class Category
|
|||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int MaxNomineesPerUser { get; set; }
|
public int MaxNomineesPerUser { get; set; }
|
||||||
|
public int? ViewerRangeMin { get; set; }
|
||||||
|
public int? ViewerRangeMax { get; set; }
|
||||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,32 @@ public sealed class Nomination
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int SeasonId { get; set; }
|
public int SeasonId { get; set; }
|
||||||
public Season Season { get; set; } = null!;
|
public Season Season { get; set; } = null!;
|
||||||
public int CategoryId { get; set; }
|
public int? CategoryId { get; set; }
|
||||||
public Category Category { get; set; } = null!;
|
public Category? Category { get; set; }
|
||||||
|
public string CategoryGroupName { get; set; } = string.Empty;
|
||||||
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
||||||
public int? CandidateId { get; set; }
|
public int? CandidateId { get; set; }
|
||||||
public Candidate? Candidate { get; set; }
|
public Candidate? Candidate { get; set; }
|
||||||
|
public int? StreamerIdentityId { get; set; }
|
||||||
|
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||||
|
public int? SuggestedCategoryId { get; set; }
|
||||||
|
public Category? SuggestedCategory { get; set; }
|
||||||
public string? CandidateText { get; set; }
|
public string? CandidateText { get; set; }
|
||||||
public string? StreamUrl { get; set; }
|
public string? StreamUrl { get; set; }
|
||||||
|
public string? ResolvedChannel { get; set; }
|
||||||
|
public string? ResolvedPlatform { get; set; }
|
||||||
|
public int? AvgViewers { get; set; }
|
||||||
|
public int? HoursStreamed { get; set; }
|
||||||
|
public int? HoursWatched { get; set; }
|
||||||
|
public int? PeakViewers { get; set; }
|
||||||
|
public int? FollowersGained { get; set; }
|
||||||
|
public string TrackerStatus { get; set; } = "pending";
|
||||||
|
public DateTimeOffset? TrackerCheckedAt { get; set; }
|
||||||
|
public string TrackingReviewStatus { get; set; } = "clear";
|
||||||
|
public string TrackingFlagsJson { get; set; } = "[]";
|
||||||
|
public string? TrackingReviewNote { get; set; }
|
||||||
|
public string? TrackingReviewedByTwitchId { get; set; }
|
||||||
|
public DateTimeOffset? TrackingReviewedAt { get; set; }
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = "pending";
|
||||||
public string? ReviewNote { get; set; }
|
public string? ReviewNote { get; set; }
|
||||||
public string? ReviewedByTwitchId { get; set; }
|
public string? ReviewedByTwitchId { get; set; }
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class Season
|
|||||||
public DateOnly ReviewEndsAt { get; set; }
|
public DateOnly ReviewEndsAt { get; set; }
|
||||||
public DateOnly ShowDate { get; set; }
|
public DateOnly ShowDate { get; set; }
|
||||||
public TimeOnly ShowStartsAt { get; set; } = new(20, 0);
|
public TimeOnly ShowStartsAt { get; set; } = new(20, 0);
|
||||||
|
public string SubcategoryTemplatesJson { get; set; } = "[]";
|
||||||
|
public string WorkflowRulesJson { get; set; } = "[]";
|
||||||
public ICollection<Category> Categories { get; set; } = [];
|
public ICollection<Category> Categories { get; set; } = [];
|
||||||
public ICollection<AwardResult> Results { get; set; } = [];
|
public ICollection<AwardResult> Results { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed class ShowactApplication
|
|||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public string TechnicalNotes { get; set; } = string.Empty;
|
public string TechnicalNotes { get; set; } = string.Empty;
|
||||||
public string ReferenceUrl { get; set; } = string.Empty;
|
public string ReferenceUrl { get; set; } = string.Empty;
|
||||||
|
public string FieldResponsesJson { get; set; } = "{}";
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = "pending";
|
||||||
public string? ReviewNote { get; set; }
|
public string? ReviewNote { get; set; }
|
||||||
public string? ReviewedByTwitchId { get; set; }
|
public string? ReviewedByTwitchId { get; set; }
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public sealed class SiteSettings
|
|||||||
public string HostDisplayName { get; set; } = string.Empty;
|
public string HostDisplayName { get; set; } = string.Empty;
|
||||||
public string HostTagline { get; set; } = string.Empty;
|
public string HostTagline { get; set; } = string.Empty;
|
||||||
public string NewsletterUrl { get; set; } = string.Empty;
|
public string NewsletterUrl { get; set; } = string.Empty;
|
||||||
|
public string ShareXUrl { get; set; } = string.Empty;
|
||||||
|
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||||
public string PrivacyEmail { get; set; } = string.Empty;
|
public string PrivacyEmail { get; set; } = string.Empty;
|
||||||
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
||||||
public string? PrivacyPolicyUpdatedBy { get; set; }
|
public string? PrivacyPolicyUpdatedBy { get; set; }
|
||||||
@@ -22,13 +24,19 @@ public sealed class SiteSettings
|
|||||||
public string FaqJson { get; set; } = "[]";
|
public string FaqJson { get; set; } = "[]";
|
||||||
public string RiskRulesJson { get; set; } = "[]";
|
public string RiskRulesJson { get; set; } = "[]";
|
||||||
public string WorkflowRulesJson { get; set; } = "[]";
|
public string WorkflowRulesJson { get; set; } = "[]";
|
||||||
|
public string TrackingRulesJson { get; set; } = "[]";
|
||||||
|
public string ViewerStatsProviderBaseUrl { get; set; } = string.Empty;
|
||||||
|
public string TrackingReviewNotes { get; set; } = string.Empty;
|
||||||
public string NominationLinkBlacklistJson { get; set; } = "[]";
|
public string NominationLinkBlacklistJson { get; set; } = "[]";
|
||||||
public bool ClipSubmissionsEnabled { get; set; }
|
public bool ClipSubmissionsEnabled { get; set; }
|
||||||
public bool ClipReviewEnabled { get; set; } = true;
|
public bool ClipReviewEnabled { get; set; } = true;
|
||||||
public bool ClipAdminMenuVisible { get; set; } = true;
|
public bool ClipAdminMenuVisible { get; set; } = true;
|
||||||
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
|
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||||
public bool ShowactApplicationsEnabled { get; set; }
|
public bool ShowactApplicationsEnabled { get; set; }
|
||||||
|
public DateOnly? ShowactApplicationStartsAt { get; set; }
|
||||||
|
public DateOnly? ShowactApplicationEndsAt { get; set; }
|
||||||
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
|
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
|
||||||
|
public string ShowactFormSchemaJson { get; set; } = "[]";
|
||||||
public bool SponsorsVisible { get; set; } = true;
|
public bool SponsorsVisible { get; set; } = true;
|
||||||
public bool DemoLoginManagedByDatabase { get; set; }
|
public bool DemoLoginManagedByDatabase { get; set; }
|
||||||
public bool DemoLoginEnabled { get; set; }
|
public bool DemoLoginEnabled { get; set; }
|
||||||
@@ -42,6 +50,7 @@ public sealed class SiteSettings
|
|||||||
public string TwitchClientSecret { get; set; } = string.Empty;
|
public string TwitchClientSecret { get; set; } = string.Empty;
|
||||||
public string TwitchRedirectUri { get; set; } = string.Empty;
|
public string TwitchRedirectUri { get; set; } = string.Empty;
|
||||||
public string TwitchScope { get; set; } = string.Empty;
|
public string TwitchScope { get; set; } = string.Empty;
|
||||||
|
public int SessionIdleTimeoutHours { get; set; } = 3;
|
||||||
public bool MaintenanceModeEnabled { get; set; }
|
public bool MaintenanceModeEnabled { get; set; }
|
||||||
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
||||||
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||||
|
|||||||
@@ -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.Status,
|
||||||
application.ReviewNote,
|
application.ReviewNote,
|
||||||
application.CreatedAt,
|
application.CreatedAt,
|
||||||
application.ReviewedAt);
|
application.ReviewedAt,
|
||||||
|
application.FieldResponsesJson ?? "{}");
|
||||||
|
|
||||||
private static string NormalizeStatus(string? value) =>
|
private static string NormalizeStatus(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ public static partial class AdminModerationEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
.WithName("RejectAdminNomination")
|
.WithName("RejectAdminNomination")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("ReopenRejectedAdminNomination")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("UpdateNominationTrackingReview")
|
||||||
|
.WithOpenApi();
|
||||||
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
.WithName("GetAdminNominationLinkBlacklist")
|
.WithName("GetAdminNominationLinkBlacklist")
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints
|
|||||||
var session = AdminEndpointConventions.CurrentSession(context);
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
var nomination = await db.Nominations
|
var nomination = await db.Nominations
|
||||||
.Include(item => item.Category)
|
.Include(item => item.Category)
|
||||||
|
.Include(item => item.SuggestedCategory)
|
||||||
|
.Include(item => item.StreamerIdentity)
|
||||||
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||||
|
|
||||||
if (nomination is null)
|
if (nomination is null)
|
||||||
@@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||||
|
if (trackingFlags.Any(flag => flag.BlocksApproval)
|
||||||
|
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
|
||||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
if (!categoryId.HasValue)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == categoryId.Value
|
||||||
|
&& item.SeasonId == nomination.SeasonId
|
||||||
|
&& item.GroupName == nomination.CategoryGroupName);
|
||||||
|
if (targetCategory is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
|
||||||
|
var platform = string.IsNullOrWhiteSpace(request.Platform)
|
||||||
|
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
|
||||||
|
: request.Platform.Trim();
|
||||||
var normalizedDisplayName = rawDisplayName.ToLower();
|
var normalizedDisplayName = rawDisplayName.ToLower();
|
||||||
var normalizedChannelSlug = channelSlug.ToLower();
|
var normalizedChannelSlug = channelSlug.ToLower();
|
||||||
var normalizedPlatform = platform.ToLower();
|
var normalizedPlatform = platform.ToLower();
|
||||||
|
|
||||||
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||||
item.SeasonId == nomination.SeasonId
|
item.SeasonId == nomination.SeasonId
|
||||||
&& item.CategoryId == nomination.CategoryId
|
&& item.CategoryId == targetCategory.Id
|
||||||
&& (
|
&& (
|
||||||
|
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||||
|
||
|
||||||
item.DisplayName.ToLower() == normalizedDisplayName
|
item.DisplayName.ToLower() == normalizedDisplayName
|
||||||
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||||
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||||
&& item.Platform.ToLower() == normalizedPlatform)
|
&& item.Platform.ToLower() == normalizedPlatform)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||||
|
db,
|
||||||
|
nomination.SeasonId,
|
||||||
|
targetCategory.Id,
|
||||||
|
existingCandidate?.Id,
|
||||||
|
nomination.StreamerIdentityId,
|
||||||
|
rawDisplayName,
|
||||||
|
channelSlug,
|
||||||
|
existingCandidate?.AcceptanceStatus ?? "open",
|
||||||
|
context.RequestAborted);
|
||||||
|
if (workflowRuleBlock is not null)
|
||||||
|
{
|
||||||
|
return workflowRuleBlock;
|
||||||
|
}
|
||||||
|
|
||||||
var candidate = existingCandidate;
|
var candidate = existingCandidate;
|
||||||
if (candidate is null)
|
if (candidate is null)
|
||||||
{
|
{
|
||||||
candidate = new Candidate
|
candidate = new Candidate
|
||||||
{
|
{
|
||||||
SeasonId = nomination.SeasonId,
|
SeasonId = nomination.SeasonId,
|
||||||
CategoryId = nomination.CategoryId,
|
CategoryId = targetCategory.Id,
|
||||||
|
StreamerIdentityId = nomination.StreamerIdentityId,
|
||||||
DisplayName = rawDisplayName,
|
DisplayName = rawDisplayName,
|
||||||
ChannelSlug = channelSlug,
|
ChannelSlug = channelSlug,
|
||||||
Platform = platform,
|
Platform = platform,
|
||||||
@@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
|
||||||
candidate.DisplayName = rawDisplayName;
|
candidate.DisplayName = rawDisplayName;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||||
@@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nomination.CandidateId = candidate.Id;
|
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||||
nomination.Status = "approved";
|
var uniqueViewerCount = relatedNominations
|
||||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
|
||||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
|
||||||
|
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
|
||||||
|
|
||||||
|
foreach (var relatedNomination in relatedNominations)
|
||||||
|
{
|
||||||
|
relatedNomination.CandidateId = candidate.Id;
|
||||||
|
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
|
||||||
|
relatedNomination.Status = "approved";
|
||||||
|
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||||
|
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||||
|
}
|
||||||
|
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
"nomination.approve",
|
"nomination.approve",
|
||||||
"nomination",
|
"nomination",
|
||||||
nomination.Id.ToString(),
|
nomination.Id.ToString(),
|
||||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
|
||||||
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
|
new
|
||||||
|
{
|
||||||
|
candidateId = candidate.Id,
|
||||||
|
created = existingCandidate is null,
|
||||||
|
targetCategoryId = targetCategory.Id,
|
||||||
|
targetCategoryName = targetCategory.Name,
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
uniqueViewerCount,
|
||||||
|
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||||
|
},
|
||||||
RequestMetadataReader.Read(context));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
saved = true,
|
||||||
|
nominationId = nomination.Id,
|
||||||
|
candidateId = candidate.Id,
|
||||||
|
created = existingCandidate is null,
|
||||||
|
uniqueViewerCount,
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RejectNomination(
|
private static async Task<IResult> RejectNomination(
|
||||||
@@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
nomination.CandidateId = null;
|
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||||
nomination.Status = "rejected";
|
foreach (var relatedNomination in relatedNominations)
|
||||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
{
|
||||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
relatedNomination.CandidateId = null;
|
||||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
relatedNomination.Status = "rejected";
|
||||||
|
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||||
|
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||||
|
}
|
||||||
|
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints
|
|||||||
"nomination",
|
"nomination",
|
||||||
nomination.Id.ToString(),
|
nomination.Id.ToString(),
|
||||||
$"Nominierung {nomination.Id} wurde verworfen.",
|
$"Nominierung {nomination.Id} wurde verworfen.",
|
||||||
new { nomination.ReviewNote },
|
new
|
||||||
|
{
|
||||||
|
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
},
|
||||||
RequestMetadataReader.Read(context));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ReopenRejectedNomination(
|
||||||
|
HttpContext context,
|
||||||
|
int nominationId,
|
||||||
|
ReopenRejectedNominationRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||||
|
if (nomination is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted);
|
||||||
|
foreach (var relatedNomination in relatedNominations)
|
||||||
|
{
|
||||||
|
relatedNomination.CandidateId = null;
|
||||||
|
relatedNomination.Status = "pending";
|
||||||
|
relatedNomination.ReviewNote = null;
|
||||||
|
relatedNomination.ReviewedAt = null;
|
||||||
|
relatedNomination.ReviewedByTwitchId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"nomination.reopen",
|
||||||
|
"nomination",
|
||||||
|
nomination.Id.ToString(),
|
||||||
|
$"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
reviewNote,
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateNominationTrackingReview(
|
||||||
|
HttpContext context,
|
||||||
|
int nominationId,
|
||||||
|
UpdateNominationTrackingReviewRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||||
|
if (nomination is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedStatus = request.Status?.Trim().ToLowerInvariant();
|
||||||
|
if (normalizedStatus is not ("reviewed" or "overridden"))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||||
|
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||||
|
var requiresOverrideNote = relatedNominations
|
||||||
|
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||||
|
.Any(flag => flag.AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." });
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in relatedNominations)
|
||||||
|
{
|
||||||
|
item.TrackingReviewStatus = normalizedStatus;
|
||||||
|
item.TrackingReviewNote = reviewNote;
|
||||||
|
item.TrackingReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
item.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"nomination.tracking-review.update",
|
||||||
|
"nomination",
|
||||||
|
nomination.Id.ToString(),
|
||||||
|
$"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
status = normalizedStatus,
|
||||||
|
reviewNote,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, nominationId, status = normalizedStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Nomination[]> FindRelatedPendingNominationsAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
Nomination nomination,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken);
|
||||||
|
|
||||||
|
private static async Task<Nomination[]> FindRelatedNominationsByStatusAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
Nomination nomination,
|
||||||
|
string status,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = db.Nominations
|
||||||
|
.Where(item =>
|
||||||
|
item.SeasonId == nomination.SeasonId
|
||||||
|
&& item.CategoryGroupName == nomination.CategoryGroupName
|
||||||
|
&& item.Status == status);
|
||||||
|
|
||||||
|
if (nomination.StreamerIdentityId.HasValue)
|
||||||
|
{
|
||||||
|
return await query
|
||||||
|
.Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedStreamUrl))
|
||||||
|
{
|
||||||
|
var rows = await query.ToArrayAsync(cancellationToken);
|
||||||
|
return rows
|
||||||
|
.Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [nomination];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeModerationStreamUrl(string? value) =>
|
||||||
|
(value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string FirstNonEmpty(params string?[] values) =>
|
||||||
|
values
|
||||||
|
.Select(value => value?.Trim() ?? string.Empty)
|
||||||
|
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
?? string.Empty;
|
||||||
|
|
||||||
|
private static async Task<IResult?> BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int seasonId,
|
||||||
|
int categoryId,
|
||||||
|
int? existingCandidateId,
|
||||||
|
int? streamerIdentityId,
|
||||||
|
string displayName,
|
||||||
|
string channelSlug,
|
||||||
|
string acceptanceStatus,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||||
|
var rules = WorkflowRuleSettings.Read(season, settings);
|
||||||
|
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||||
|
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||||
|
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingCandidates = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.SeasonId == seasonId
|
||||||
|
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
|
||||||
|
&& item.AcceptanceStatus != "declined")
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.DisplayName,
|
||||||
|
item.ChannelSlug,
|
||||||
|
})
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||||
|
{
|
||||||
|
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||||
|
if (categoryCount >= finalistsRule.Limit)
|
||||||
|
{
|
||||||
|
return CreateModerationWorkflowRuleError(
|
||||||
|
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||||
|
{
|
||||||
|
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||||
|
var appearanceCount = existingCandidates.Count(item =>
|
||||||
|
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||||
|
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||||
|
if (appearanceCount >= appearancesRule.Limit)
|
||||||
|
{
|
||||||
|
return CreateModerationWorkflowRuleError(
|
||||||
|
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult CreateModerationWorkflowRuleError(string message) =>
|
||||||
|
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||||
|
|
||||||
|
private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId)
|
||||||
|
{
|
||||||
|
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||||
|
if (trackingFlags.Length == 0)
|
||||||
|
{
|
||||||
|
nomination.TrackingReviewStatus = "clear";
|
||||||
|
nomination.TrackingReviewNote = null;
|
||||||
|
nomination.TrackingReviewedByTwitchId = null;
|
||||||
|
nomination.TrackingReviewedAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim();
|
||||||
|
nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note))
|
||||||
|
? "overridden"
|
||||||
|
: "reviewed";
|
||||||
|
nomination.TrackingReviewNote = note;
|
||||||
|
nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId;
|
||||||
|
nomination.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
seasonId,
|
seasonId,
|
||||||
request.CategoryId,
|
request.CategoryId,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
normalizedDisplayName,
|
normalizedDisplayName,
|
||||||
normalizedChannelSlug,
|
normalizedChannelSlug,
|
||||||
normalizedAcceptanceStatus,
|
normalizedAcceptanceStatus,
|
||||||
@@ -158,6 +159,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
candidate.SeasonId,
|
candidate.SeasonId,
|
||||||
request.CategoryId,
|
request.CategoryId,
|
||||||
candidateId,
|
candidateId,
|
||||||
|
candidate.StreamerIdentityId,
|
||||||
normalizedDisplayName,
|
normalizedDisplayName,
|
||||||
normalizedChannelSlug,
|
normalizedChannelSlug,
|
||||||
normalizedAcceptanceStatus,
|
normalizedAcceptanceStatus,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
Description = request.Description.Trim(),
|
Description = request.Description.Trim(),
|
||||||
SortOrder = request.SortOrder,
|
SortOrder = request.SortOrder,
|
||||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||||
|
ViewerRangeMin = request.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = request.ViewerRangeMax,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Categories.Add(category);
|
db.Categories.Add(category);
|
||||||
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
category.Description = request.Description.Trim();
|
category.Description = request.Description.Trim();
|
||||||
category.SortOrder = request.SortOrder;
|
category.SortOrder = request.SortOrder;
|
||||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||||
|
category.ViewerRangeMin = request.ViewerRangeMin;
|
||||||
|
category.ViewerRangeMax = request.ViewerRangeMax;
|
||||||
|
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> UpdateSeasonSubcategoryTemplates(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
UpdateSeasonSubcategoryTemplatesRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var validationError = ValidateSubcategoryTemplatesRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories = await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||||
|
var blockedRemovals = await FindBlockedSubcategoryRemovalsAsync(db, categories, templates, context.RequestAborted);
|
||||||
|
if (blockedRemovals.Length > 0)
|
||||||
|
{
|
||||||
|
var firstBlocked = blockedRemovals[0];
|
||||||
|
return Results.BadRequest(new
|
||||||
|
{
|
||||||
|
message = $"Unterkategorie \"{firstBlocked.SubcategoryName}\" kann nicht entfernt werden, weil darunter noch {firstBlocked.CandidateCount} Kandidaten und {firstBlocked.NominationCount} Nominierungen haengen.",
|
||||||
|
blockedSubcategories = blockedRemovals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||||
|
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category-templates.update",
|
||||||
|
"season",
|
||||||
|
seasonId.ToString(),
|
||||||
|
$"Unterkategorien für {season.Year} wurden aktualisiert.",
|
||||||
|
new { seasonId, templateCount = templates.Length },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, seasonId, templateCount = templates.Length });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateCategoryGroup(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
UpsertCategoryGroupRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var validationError = ValidateCategoryGroupRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories = await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||||
|
if (templates.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Lege zuerst mindestens eine globale Unterkategorie an." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupName = request.GroupName.Trim();
|
||||||
|
if (categories.Any(item => string.Equals(item.GroupName, groupName, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < templates.Length; index += 1)
|
||||||
|
{
|
||||||
|
categories.Add(new Category
|
||||||
|
{
|
||||||
|
SeasonId = seasonId,
|
||||||
|
GroupName = groupName,
|
||||||
|
Name = templates[index].Name,
|
||||||
|
Slug = BuildCategorySlug(groupName, templates[index].Slug),
|
||||||
|
Description = request.Description.Trim(),
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||||
|
ViewerRangeMin = templates[index].ViewerRangeMin,
|
||||||
|
ViewerRangeMax = templates[index].ViewerRangeMax,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var category in categories.Where(item => item.Id == 0))
|
||||||
|
{
|
||||||
|
db.Categories.Add(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category-group.create",
|
||||||
|
"season",
|
||||||
|
seasonId.ToString(),
|
||||||
|
$"Hauptkategorie {groupName} wurde angelegt.",
|
||||||
|
new { seasonId, groupName, request.SortOrder, request.MaxNomineesPerUser },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, seasonId, groupName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateCategoryGroup(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
string groupName,
|
||||||
|
UpsertCategoryGroupRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var validationError = ValidateCategoryGroupRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories = await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||||
|
var normalizedCurrentName = groupName.Trim();
|
||||||
|
var groupCategories = categories
|
||||||
|
.Where(item => string.Equals(item.GroupName, normalizedCurrentName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
if (groupCategories.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetName = request.GroupName.Trim();
|
||||||
|
if (!string.Equals(normalizedCurrentName, targetName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& categories.Any(item => string.Equals(item.GroupName, targetName, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var category in groupCategories)
|
||||||
|
{
|
||||||
|
category.GroupName = targetName;
|
||||||
|
category.Description = request.Description.Trim();
|
||||||
|
category.SortOrder = request.SortOrder;
|
||||||
|
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category-group.update",
|
||||||
|
"season",
|
||||||
|
seasonId.ToString(),
|
||||||
|
$"Hauptkategorie {normalizedCurrentName} wurde aktualisiert.",
|
||||||
|
new { seasonId, from = normalizedCurrentName, to = targetName, request.SortOrder, request.MaxNomineesPerUser },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, seasonId, groupName = targetName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteCategoryGroup(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
string groupName,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var normalizedGroupName = groupName.Trim();
|
||||||
|
var categories = await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.GroupName.ToLower() == normalizedGroupName.ToLower())
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
if (categories.Count == 0)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryIds = categories.Select(item => item.Id).ToArray();
|
||||||
|
var candidates = await db.Candidates
|
||||||
|
.Where(item => categoryIds.Contains(item.CategoryId))
|
||||||
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
if (candidates.Length > 0)
|
||||||
|
{
|
||||||
|
db.Candidates.RemoveRange(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Categories.RemoveRange(categories);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category-group.delete",
|
||||||
|
"season",
|
||||||
|
seasonId.ToString(),
|
||||||
|
$"Hauptkategorie {normalizedGroupName} wurde gelöscht.",
|
||||||
|
new { seasonId, removedCategories = categories.Count, removedCandidates = candidates.Length },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, seasonId, groupName = normalizedGroupName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SyncCategoryGroupsToTemplates(
|
||||||
|
AwardsDbContext db,
|
||||||
|
Season season,
|
||||||
|
List<Category> categories,
|
||||||
|
SeasonSubcategoryTemplateSetting[] templates)
|
||||||
|
{
|
||||||
|
var orderedGroups = categories
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||||
|
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var items = group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList();
|
||||||
|
var sample = items[0];
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
GroupName = sample.GroupName.Trim(),
|
||||||
|
Description = sample.Description.Trim(),
|
||||||
|
SortOrder = items.Min(item => item.SortOrder),
|
||||||
|
MaxNomineesPerUser = sample.MaxNomineesPerUser,
|
||||||
|
Items = items,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderBy(group => group.SortOrder)
|
||||||
|
.ThenBy(group => group.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var nextSortOrder = 1;
|
||||||
|
foreach (var group in orderedGroups)
|
||||||
|
{
|
||||||
|
var usedCategories = new HashSet<Category>();
|
||||||
|
for (var index = 0; index < templates.Length; index += 1)
|
||||||
|
{
|
||||||
|
var template = templates[index];
|
||||||
|
var category = FindReusableCategoryForTemplate(group.Items, template, group.GroupName, usedCategories)
|
||||||
|
?? new Category { SeasonId = season.Id };
|
||||||
|
usedCategories.Add(category);
|
||||||
|
|
||||||
|
category.GroupName = group.GroupName;
|
||||||
|
category.Name = template.Name;
|
||||||
|
category.Slug = BuildCategorySlug(group.GroupName, template.Slug);
|
||||||
|
category.Description = group.Description;
|
||||||
|
category.MaxNomineesPerUser = group.MaxNomineesPerUser;
|
||||||
|
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||||
|
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||||
|
category.SortOrder = nextSortOrder++;
|
||||||
|
|
||||||
|
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||||
|
{
|
||||||
|
db.Categories.Add(category);
|
||||||
|
categories.Add(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var staleCategory in FindStaleCategoriesAfterTemplateSync(group.Items, templates, group.GroupName))
|
||||||
|
{
|
||||||
|
RemoveCategoryWithCandidates(db, staleCategory);
|
||||||
|
categories.Remove(staleCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveCategoryWithCandidates(AwardsDbContext db, Category category)
|
||||||
|
{
|
||||||
|
if (category.Id > 0)
|
||||||
|
{
|
||||||
|
var candidates = db.Candidates.Where(item => item.CategoryId == category.Id).ToArray();
|
||||||
|
if (candidates.Length > 0)
|
||||||
|
{
|
||||||
|
db.Candidates.RemoveRange(candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Categories.Remove(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<BlockedSubcategoryRemoval[]> FindBlockedSubcategoryRemovalsAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
List<Category> categories,
|
||||||
|
SeasonSubcategoryTemplateSetting[] templates,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var staleCategories = categories
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||||
|
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.SelectMany(group => FindStaleCategoriesAfterTemplateSync(
|
||||||
|
group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(),
|
||||||
|
templates,
|
||||||
|
group.Key))
|
||||||
|
.Where(item => item.Id > 0)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (staleCategories.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
|
||||||
|
var candidateCounts = await db.Candidates
|
||||||
|
.Where(item => staleCategoryIds.Contains(item.CategoryId))
|
||||||
|
.GroupBy(item => item.CategoryId)
|
||||||
|
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||||
|
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||||
|
var nominationCounts = await db.Nominations
|
||||||
|
.Where(item =>
|
||||||
|
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||||
|
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value)))
|
||||||
|
.GroupBy(item => item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)
|
||||||
|
? item.CategoryId!.Value
|
||||||
|
: item.SuggestedCategoryId!.Value)
|
||||||
|
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||||
|
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||||
|
|
||||||
|
return staleCategories
|
||||||
|
.Select(category => new BlockedSubcategoryRemoval(
|
||||||
|
category.GroupName,
|
||||||
|
category.Name,
|
||||||
|
category.Slug,
|
||||||
|
candidateCounts.GetValueOrDefault(category.Id),
|
||||||
|
nominationCounts.GetValueOrDefault(category.Id)))
|
||||||
|
.Where(item => item.CandidateCount > 0 || item.NominationCount > 0)
|
||||||
|
.OrderBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(item => item.SubcategoryName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Category? FindReusableCategoryForTemplate(
|
||||||
|
List<Category> categories,
|
||||||
|
SeasonSubcategoryTemplateSetting template,
|
||||||
|
string groupName,
|
||||||
|
HashSet<Category>? usedCategories = null)
|
||||||
|
{
|
||||||
|
usedCategories ??= [];
|
||||||
|
var expectedSlug = BuildCategorySlug(groupName, template.Slug);
|
||||||
|
var normalizedTemplateSlug = SeasonSubcategoryTemplateSettings.Slugify(template.Slug);
|
||||||
|
|
||||||
|
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||||
|
&& string.Equals(item.Slug, expectedSlug, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||||
|
&& string.Equals(item.Slug, normalizedTemplateSlug, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||||
|
&& item.Slug.EndsWith($"-{normalizedTemplateSlug}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||||
|
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Category[] FindStaleCategoriesAfterTemplateSync(
|
||||||
|
List<Category> categories,
|
||||||
|
SeasonSubcategoryTemplateSetting[] templates,
|
||||||
|
string groupName)
|
||||||
|
{
|
||||||
|
var usedCategories = new HashSet<Category>();
|
||||||
|
foreach (var template in templates)
|
||||||
|
{
|
||||||
|
var reusableCategory = FindReusableCategoryForTemplate(categories, template, groupName, usedCategories);
|
||||||
|
if (reusableCategory is not null)
|
||||||
|
{
|
||||||
|
usedCategories.Add(reusableCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories
|
||||||
|
.Where(item => !usedCategories.Contains(item))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateSubcategoryTemplatesRequest(UpdateSeasonSubcategoryTemplatesRequest request)
|
||||||
|
{
|
||||||
|
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||||
|
if (templates.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Mindestens eine Unterkategorie ist erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateNames = templates
|
||||||
|
.GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Any(group => group.Count() > 1);
|
||||||
|
if (duplicateNames)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Namen mehrfach verwenden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateSlugs = templates
|
||||||
|
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Any(group => group.Count() > 1);
|
||||||
|
if (duplicateSlugs)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Slug mehrfach verwenden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var template in templates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 120)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Unterkategorie-Name ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(template.Slug) || template.Slug.Length > 120)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Unterkategorie-Slug ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.ViewerRangeMax is not null
|
||||||
|
&& template.ViewerRangeMin is not null
|
||||||
|
&& template.ViewerRangeMax < template.ViewerRangeMin)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Viewer-Range Ende muss groesser oder gleich dem Start sein." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateCategoryGroupRequest(UpsertCategoryGroupRequest request)
|
||||||
|
{
|
||||||
|
var groupName = request.GroupName.Trim();
|
||||||
|
var description = request.Description.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > 80)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Hauptkategorie ist erforderlich und muss unter 80 Zeichen bleiben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.Length > 400)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Beschreibung muss unter 400 Zeichen bleiben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SortOrder is < 1 or > 200)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Die Reihenfolge muss zwischen 1 und 200 liegen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.MaxNomineesPerUser is < 1 or > 10)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das Nominierungs-Limit muss zwischen 1 und 10 liegen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record BlockedSubcategoryRemoval(
|
||||||
|
string GroupName,
|
||||||
|
string SubcategoryName,
|
||||||
|
string Slug,
|
||||||
|
int CandidateCount,
|
||||||
|
int NominationCount);
|
||||||
|
|
||||||
|
private static string BuildCategorySlug(string groupName, string templateSlug)
|
||||||
|
{
|
||||||
|
var groupSlug = SeasonSubcategoryTemplateSettings.Slugify(groupName);
|
||||||
|
var detailSlug = SeasonSubcategoryTemplateSettings.Slugify(templateSlug);
|
||||||
|
return string.IsNullOrWhiteSpace(groupSlug) ? detailSlug : $"{groupSlug}-{detailSlug}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||||
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
|
var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings));
|
||||||
|
|
||||||
var season = new Season
|
var season = new Season
|
||||||
{
|
{
|
||||||
@@ -45,6 +49,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
ReviewEndsAt = request.ReviewEndsAt,
|
ReviewEndsAt = request.ReviewEndsAt,
|
||||||
ShowDate = request.ShowDate,
|
ShowDate = request.ShowDate,
|
||||||
ShowStartsAt = request.ShowStartsAt,
|
ShowStartsAt = request.ShowStartsAt,
|
||||||
|
SubcategoryTemplatesJson = "[]",
|
||||||
|
WorkflowRulesJson = initialWorkflowRulesJson,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Seasons.Add(season);
|
db.Seasons.Add(season);
|
||||||
@@ -72,6 +78,13 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.ToArrayAsync(context.RequestAborted);
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
|
||||||
copiedCategoryCount = sourceCategories.Length;
|
copiedCategoryCount = sourceCategories.Length;
|
||||||
|
var sourceSeason = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||||
|
season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson;
|
||||||
|
season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson)
|
||||||
|
? initialWorkflowRulesJson
|
||||||
|
: sourceSeason.WorkflowRulesJson;
|
||||||
foreach (var category in sourceCategories)
|
foreach (var category in sourceCategories)
|
||||||
{
|
{
|
||||||
db.Categories.Add(new Category
|
db.Categories.Add(new Category
|
||||||
@@ -83,6 +96,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
Description = category.Description,
|
Description = category.Description,
|
||||||
SortOrder = category.SortOrder,
|
SortOrder = category.SortOrder,
|
||||||
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||||
|
ViewerRangeMin = category.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = category.ViewerRangeMax,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
|
using Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||||
|
|
||||||
var candidates = await db.Candidates
|
var candidates = await db.Candidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.Select(item => new AdminCandidateItemDto(
|
.Select(item => new AdminCandidateItemDto(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
item.DisplayName,
|
item.DisplayName,
|
||||||
item.ChannelSlug,
|
item.ChannelSlug,
|
||||||
item.Platform,
|
item.Platform,
|
||||||
|
item.NominationTally,
|
||||||
item.AcceptanceStatus,
|
item.AcceptanceStatus,
|
||||||
item.AcceptanceNote,
|
item.AcceptanceNote,
|
||||||
item.ClipCompilationUrl,
|
item.ClipCompilationUrl,
|
||||||
@@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
category.Description,
|
category.Description,
|
||||||
category.SortOrder,
|
category.SortOrder,
|
||||||
category.MaxNomineesPerUser,
|
category.MaxNomineesPerUser,
|
||||||
|
category.ViewerRangeMin,
|
||||||
|
category.ViewerRangeMax,
|
||||||
})
|
})
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
|
||||||
|
season,
|
||||||
|
categoryRows.Select(category => new Backend.Domain.Category
|
||||||
|
{
|
||||||
|
GroupName = category.GroupName,
|
||||||
|
Name = category.Name,
|
||||||
|
Slug = category.Slug,
|
||||||
|
SortOrder = category.SortOrder,
|
||||||
|
ViewerRangeMin = category.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = category.ViewerRangeMax,
|
||||||
|
}));
|
||||||
|
var visibleCategoryRows = categoryRows
|
||||||
|
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
|
||||||
|
new Backend.Domain.Category
|
||||||
|
{
|
||||||
|
GroupName = category.GroupName,
|
||||||
|
Name = category.Name,
|
||||||
|
Slug = category.Slug,
|
||||||
|
SortOrder = category.SortOrder,
|
||||||
|
ViewerRangeMin = category.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = category.ViewerRangeMax,
|
||||||
|
},
|
||||||
|
subcategoryTemplateSettings))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var categories = categoryRows
|
var categories = visibleCategoryRows
|
||||||
.Select(category => new AdminCategoryItemDto(
|
.Select(category => new AdminCategoryItemDto(
|
||||||
category.Id,
|
category.Id,
|
||||||
category.GroupName,
|
category.GroupName,
|
||||||
@@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
category.Description,
|
category.Description,
|
||||||
category.SortOrder,
|
category.SortOrder,
|
||||||
category.MaxNomineesPerUser,
|
category.MaxNomineesPerUser,
|
||||||
|
category.ViewerRangeMin,
|
||||||
|
category.ViewerRangeMax,
|
||||||
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var pendingNominations = await db.Nominations
|
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||||
|
|
||||||
|
var pendingNominationRows = await db.Nominations
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||||
.OrderByDescending(item => item.CreatedAt)
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
.Select(item => new AdminNominationReviewItemDto(
|
.Select(item => new AdminNominationRow(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.Category.Name,
|
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||||
|
item.CategoryId != null ? item.Category!.Name : null,
|
||||||
item.SubmittedByTwitchId,
|
item.SubmittedByTwitchId,
|
||||||
item.CandidateText ?? string.Empty,
|
item.CandidateText ?? string.Empty,
|
||||||
item.StreamUrl,
|
item.StreamUrl,
|
||||||
|
item.ResolvedChannel,
|
||||||
|
item.ResolvedPlatform,
|
||||||
|
item.AvgViewers,
|
||||||
|
item.HoursStreamed,
|
||||||
|
item.HoursWatched,
|
||||||
|
item.PeakViewers,
|
||||||
|
item.FollowersGained,
|
||||||
|
item.SuggestedCategoryId,
|
||||||
|
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.TrackerStatus,
|
||||||
|
item.TrackerCheckedAt,
|
||||||
|
item.TrackingReviewStatus,
|
||||||
|
item.TrackingFlagsJson,
|
||||||
|
item.TrackingReviewNote,
|
||||||
|
item.TrackingReviewedByTwitchId,
|
||||||
|
item.TrackingReviewedAt,
|
||||||
item.Status,
|
item.Status,
|
||||||
item.CreatedAt,
|
item.CreatedAt,
|
||||||
item.CandidateId,
|
item.CandidateId,
|
||||||
@@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.ReviewedAt))
|
item.ReviewedAt))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var reviewedNominations = await db.Nominations
|
var pendingNominations = pendingNominationRows
|
||||||
|
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
|
||||||
|
|
||||||
|
var reviewedNominationRows = await db.Nominations
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||||
.Select(item => new AdminNominationReviewItemDto(
|
.Select(item => new AdminNominationRow(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.Category.Name,
|
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||||
|
item.CategoryId != null ? item.Category!.Name : null,
|
||||||
item.SubmittedByTwitchId,
|
item.SubmittedByTwitchId,
|
||||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||||
item.StreamUrl,
|
item.StreamUrl,
|
||||||
|
item.ResolvedChannel,
|
||||||
|
item.ResolvedPlatform,
|
||||||
|
item.AvgViewers,
|
||||||
|
item.HoursStreamed,
|
||||||
|
item.HoursWatched,
|
||||||
|
item.PeakViewers,
|
||||||
|
item.FollowersGained,
|
||||||
|
item.SuggestedCategoryId,
|
||||||
|
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.TrackerStatus,
|
||||||
|
item.TrackerCheckedAt,
|
||||||
|
item.TrackingReviewStatus,
|
||||||
|
item.TrackingFlagsJson,
|
||||||
|
item.TrackingReviewNote,
|
||||||
|
item.TrackingReviewedByTwitchId,
|
||||||
|
item.TrackingReviewedAt,
|
||||||
item.Status,
|
item.Status,
|
||||||
item.CreatedAt,
|
item.CreatedAt,
|
||||||
item.CandidateId,
|
item.CandidateId,
|
||||||
@@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.ReviewedAt))
|
item.ReviewedAt))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var reviewedNominations = reviewedNominationRows
|
||||||
|
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var resultItems = await db.Results
|
var resultItems = await db.Results
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.Category.Name,
|
item.Category.Name,
|
||||||
item.CandidateId,
|
item.CandidateId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
item.Candidate.DisplayName,
|
item.Candidate.DisplayName,
|
||||||
item.Candidate.ChannelSlug,
|
item.Candidate.ChannelSlug,
|
||||||
item.Candidate.Platform))
|
item.Candidate.Platform))
|
||||||
@@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
season.ReviewEndsAt,
|
season.ReviewEndsAt,
|
||||||
season.ShowDate,
|
season.ShowDate,
|
||||||
season.ShowStartsAt,
|
season.ShowStartsAt,
|
||||||
|
subcategoryTemplates,
|
||||||
categories,
|
categories,
|
||||||
candidates,
|
candidates,
|
||||||
pendingNominations,
|
pendingNominations,
|
||||||
|
pendingNominationGroups,
|
||||||
reviewedNominations,
|
reviewedNominations,
|
||||||
|
settings?.TrackingReviewNotes ?? string.Empty,
|
||||||
|
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||||
resultItems,
|
resultItems,
|
||||||
clipSubmissions));
|
clipSubmissions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record AdminNominationRow(
|
||||||
|
int Id,
|
||||||
|
int? CategoryId,
|
||||||
|
string? CategoryGroupName,
|
||||||
|
string? CategoryName,
|
||||||
|
string SubmittedByTwitchId,
|
||||||
|
string CandidateText,
|
||||||
|
string? StreamUrl,
|
||||||
|
string? ResolvedChannel,
|
||||||
|
string? ResolvedPlatform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int? HoursStreamed,
|
||||||
|
int? HoursWatched,
|
||||||
|
int? PeakViewers,
|
||||||
|
int? FollowersGained,
|
||||||
|
int? SuggestedCategoryId,
|
||||||
|
string? SuggestedCategoryName,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string TrackerStatus,
|
||||||
|
DateTimeOffset? TrackerCheckedAt,
|
||||||
|
string TrackingReviewStatus,
|
||||||
|
string TrackingFlagsJson,
|
||||||
|
string? TrackingReviewNote,
|
||||||
|
string? TrackingReviewedByTwitchId,
|
||||||
|
DateTimeOffset? TrackingReviewedAt,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
int? CandidateId,
|
||||||
|
string? CandidateDisplayName,
|
||||||
|
string? ReviewNote,
|
||||||
|
string? ReviewedByTwitchId,
|
||||||
|
DateTimeOffset? ReviewedAt);
|
||||||
|
|
||||||
|
private static AdminNominationReviewItemDto ToNominationReviewItem(
|
||||||
|
AdminNominationRow item,
|
||||||
|
IEnumerable<dynamic> categoryRows,
|
||||||
|
TrackingRulesConfiguration trackingRules)
|
||||||
|
{
|
||||||
|
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
|
||||||
|
.Select(ToTrackingFlagHitDto)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
ResolveCategoryGroupName(item, categoryRows),
|
||||||
|
ResolveCategoryName(item, categoryRows),
|
||||||
|
item.SubmittedByTwitchId,
|
||||||
|
item.CandidateText,
|
||||||
|
item.StreamUrl,
|
||||||
|
item.ResolvedChannel,
|
||||||
|
item.ResolvedPlatform,
|
||||||
|
item.AvgViewers,
|
||||||
|
item.SuggestedCategoryId,
|
||||||
|
item.SuggestedCategoryName,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
|
||||||
|
item.TrackerCheckedAt,
|
||||||
|
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
|
||||||
|
trackingFlags.Any(flag => flag.RequiresManualReview),
|
||||||
|
trackingFlags,
|
||||||
|
BuildTrackingMetricStateDtos(item, trackingRules),
|
||||||
|
item.TrackingReviewNote,
|
||||||
|
item.TrackingReviewedByTwitchId,
|
||||||
|
item.TrackingReviewedAt,
|
||||||
|
item.Status,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.CandidateId,
|
||||||
|
item.CandidateDisplayName,
|
||||||
|
item.ReviewNote,
|
||||||
|
item.ReviewedByTwitchId,
|
||||||
|
item.ReviewedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
|
||||||
|
IEnumerable<AdminNominationRow> rows,
|
||||||
|
IEnumerable<dynamic> categoryRows,
|
||||||
|
TrackingRulesConfiguration trackingRules) =>
|
||||||
|
rows
|
||||||
|
.GroupBy(item => new
|
||||||
|
{
|
||||||
|
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
|
||||||
|
IdentityKey = item.StreamerIdentityId.HasValue
|
||||||
|
? $"identity:{item.StreamerIdentityId.Value}"
|
||||||
|
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
|
||||||
|
})
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
|
||||||
|
var representative = ordered
|
||||||
|
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
|
||||||
|
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
|
||||||
|
.ThenByDescending(item => item.AvgViewers.HasValue)
|
||||||
|
.First();
|
||||||
|
var trackerStatus = ResolveGroupTrackerStatus(ordered);
|
||||||
|
var trackingFlags = ordered
|
||||||
|
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||||
|
.GroupBy(item => item.Key)
|
||||||
|
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
|
||||||
|
.ToArray();
|
||||||
|
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
|
||||||
|
return new AdminNominationReviewGroupDto(
|
||||||
|
representative.Id,
|
||||||
|
ordered.Select(item => item.Id).ToArray(),
|
||||||
|
ResolveCategoryGroupName(representative, categoryRows),
|
||||||
|
ResolveNominationDisplayName(representative),
|
||||||
|
representative.StreamUrl,
|
||||||
|
representative.ResolvedChannel,
|
||||||
|
representative.ResolvedPlatform,
|
||||||
|
representative.AvgViewers,
|
||||||
|
representative.SuggestedCategoryId,
|
||||||
|
representative.SuggestedCategoryName,
|
||||||
|
representative.StreamerIdentityId,
|
||||||
|
trackerStatus,
|
||||||
|
representative.TrackerCheckedAt,
|
||||||
|
ResolveGroupTrackingReviewStatus(ordered),
|
||||||
|
requiresManualReview,
|
||||||
|
trackingFlags,
|
||||||
|
BuildTrackingMetricStateDtos(representative, trackingRules),
|
||||||
|
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
|
||||||
|
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
|
||||||
|
ordered.Max(item => item.TrackingReviewedAt),
|
||||||
|
ordered.Length,
|
||||||
|
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
|
||||||
|
ordered.First().CreatedAt,
|
||||||
|
ordered.Last().CreatedAt);
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
|
||||||
|
.ThenByDescending(item => item.RequiresManualReview)
|
||||||
|
.ThenByDescending(item => item.NominationTally)
|
||||||
|
.ThenByDescending(item => item.UniqueSubmitterCount)
|
||||||
|
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
|
||||||
|
.ThenByDescending(item => item.AvgViewers ?? -1)
|
||||||
|
.ThenByDescending(item => item.LastSubmittedAt)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
|
||||||
|
item.ResolvedChannel
|
||||||
|
?? item.CandidateText
|
||||||
|
?? item.StreamUrl
|
||||||
|
?? "Name im Review festlegen";
|
||||||
|
|
||||||
|
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||||
|
{
|
||||||
|
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
|
||||||
|
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
?? rows.FirstOrDefault()?.TrackerStatus
|
||||||
|
?? "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||||
|
{
|
||||||
|
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
|
||||||
|
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
?? rows.FirstOrDefault()?.TrackingReviewStatus
|
||||||
|
?? "clear";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
|
||||||
|
{
|
||||||
|
return item.CategoryGroupName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||||
|
return category?.GroupName ?? "Unbekannte Hauptkategorie";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.CategoryName))
|
||||||
|
{
|
||||||
|
return item.CategoryName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||||
|
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
|
||||||
|
new(
|
||||||
|
flag.Key,
|
||||||
|
flag.Label,
|
||||||
|
flag.Severity,
|
||||||
|
flag.Description,
|
||||||
|
flag.RequiresManualReview,
|
||||||
|
flag.BlocksApproval,
|
||||||
|
flag.AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
|
||||||
|
AdminNominationRow row,
|
||||||
|
TrackingRulesConfiguration trackingRules) =>
|
||||||
|
trackingRules.ImportantMetrics
|
||||||
|
.Concat(trackingRules.OptionalMetrics)
|
||||||
|
.Where(metric => metric.Enabled && metric.ShowInReview)
|
||||||
|
.Select(metric => new AdminTrackingMetricStateDto(
|
||||||
|
metric.Key,
|
||||||
|
metric.Label,
|
||||||
|
metric.RequiredForAutoClassification,
|
||||||
|
metric.SourceSupport,
|
||||||
|
MetricPresent(metric, row),
|
||||||
|
MetricValue(metric, row),
|
||||||
|
metric.Description,
|
||||||
|
metric.WindowKey,
|
||||||
|
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||||
|
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||||
|
{
|
||||||
|
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metric.Key switch
|
||||||
|
{
|
||||||
|
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
|
||||||
|
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
|
||||||
|
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
|
||||||
|
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
|
||||||
|
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
|
||||||
|
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
|
||||||
|
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||||
|
{
|
||||||
|
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||||
|
{
|
||||||
|
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return metric.Key switch
|
||||||
|
{
|
||||||
|
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
|
||||||
|
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
|
||||||
|
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||||
|
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
|
||||||
|
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
|
||||||
|
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
|
||||||
|
TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen",
|
||||||
|
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
|
||||||
|
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
|
||||||
|
_ => "manuell",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (metric.TopCount.HasValue)
|
||||||
|
{
|
||||||
|
parts.Add($"Top {metric.TopCount.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metric.MinPrimaryCategorySharePercent.HasValue)
|
||||||
|
{
|
||||||
|
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metric.MinPrimaryCategoryHours.HasValue)
|
||||||
|
{
|
||||||
|
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
|
||||||
|
{
|
||||||
|
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metric.IgnoredCategories.Length > 0)
|
||||||
|
{
|
||||||
|
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.Count > 0
|
||||||
|
? string.Join(" · ", parts)
|
||||||
|
: "Top-Kategorien manuell pruefen";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
.WithName("DeleteAdminCategory")
|
.WithName("DeleteAdminCategory")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("UpdateAdminSeasonSubcategoryTemplates")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("CreateAdminCategoryGroup")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("UpdateAdminCategoryGroup")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("DeleteAdminCategoryGroup")
|
||||||
|
.WithOpenApi();
|
||||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
.WithName("CreateAdminCandidate")
|
.WithName("CreateAdminCandidate")
|
||||||
@@ -56,11 +72,11 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||||
.WithName("DeleteAdminResult")
|
.WithName("DeleteAdminResult")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
group.MapGet("/workflow-rules", GetWorkflowRules)
|
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||||
.WithName("GetAdminWorkflowRules")
|
.WithName("GetAdminWorkflowRules")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
group.MapPut("/workflow-rules", UpdateWorkflowRules)
|
group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||||
.WithName("UpdateAdminWorkflowRules")
|
.WithName("UpdateAdminWorkflowRules")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|||||||
@@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
private sealed record CandidateRuleSnapshot(
|
private sealed record CandidateRuleSnapshot(
|
||||||
int Id,
|
int Id,
|
||||||
int CategoryId,
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
string ChannelSlug,
|
||||||
string AcceptanceStatus);
|
string AcceptanceStatus);
|
||||||
|
|
||||||
|
private sealed record CandidateReadinessSnapshot(
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string AcceptanceStatus,
|
||||||
|
string? ClipCompilationUrl);
|
||||||
|
|
||||||
|
private sealed record WinnerReadinessSnapshot(
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string? ClipCompilationUrl);
|
||||||
|
|
||||||
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||||
{
|
{
|
||||||
if (request.Year < 2020 || request.Year > 2100)
|
if (request.Year < 2020 || request.Year > 2100)
|
||||||
@@ -145,13 +161,16 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
|
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||||
var settings = await db.SiteSettings
|
var settings = await db.SiteSettings
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||||
|
|
||||||
return WorkflowRuleSettings.Read(settings);
|
return WorkflowRuleSettings.Read(season, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult CreateWorkflowRuleError(string message) =>
|
private static IResult CreateWorkflowRuleError(string message) =>
|
||||||
@@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
int seasonId,
|
int seasonId,
|
||||||
int categoryId,
|
int categoryId,
|
||||||
int? existingCandidateId,
|
int? existingCandidateId,
|
||||||
|
int? streamerIdentityId,
|
||||||
string displayName,
|
string displayName,
|
||||||
string channelSlug,
|
string channelSlug,
|
||||||
string acceptanceStatus,
|
string acceptanceStatus,
|
||||||
@@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
|
var rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||||
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||||
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||||
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||||
@@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.Select(item => new CandidateRuleSnapshot(
|
.Select(item => new CandidateRuleSnapshot(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
item.DisplayName,
|
item.DisplayName,
|
||||||
item.ChannelSlug,
|
item.ChannelSlug,
|
||||||
item.AcceptanceStatus))
|
item.AcceptanceStatus))
|
||||||
@@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
{
|
{
|
||||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||||
var appearanceCount = existingCandidates.Count(item =>
|
var appearanceCount = existingCandidates.Count(item =>
|
||||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||||
|
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||||
if (appearanceCount >= appearancesRule.Limit)
|
if (appearanceCount >= appearancesRule.Limit)
|
||||||
{
|
{
|
||||||
return CreateWorkflowRuleError(
|
return CreateWorkflowRuleError(
|
||||||
@@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
bool isCurrent,
|
bool isCurrent,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."];
|
||||||
|
}
|
||||||
|
|
||||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||||
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||||
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||||
@@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||||
|
var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||||
|
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||||
|
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||||
|
|
||||||
var categoryIds = await db.Categories
|
var categoryIds = await db.Categories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||||
{
|
{
|
||||||
var categoriesWithCandidates = await db.Candidates
|
var candidateSnapshots = await db.Candidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => new CandidateReadinessSnapshot(
|
||||||
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.DisplayName,
|
||||||
|
item.ChannelSlug,
|
||||||
|
item.AcceptanceStatus,
|
||||||
|
item.ClipCompilationUrl))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var activeCandidates = candidateSnapshots
|
||||||
|
.Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
var categoriesWithCandidates = activeCandidates
|
||||||
.Select(item => item.CategoryId)
|
.Select(item => item.CategoryId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.CountAsync(cancellationToken);
|
.Count();
|
||||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||||
if (emptyCategories > 0)
|
if (emptyCategories > 0)
|
||||||
{
|
{
|
||||||
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||||
|
{
|
||||||
|
var identityOverflow = activeCandidates
|
||||||
|
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Count = group.Count(),
|
||||||
|
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||||
|
})
|
||||||
|
.Where(item => item.Count > appearancesRule.Limit)
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (identityOverflow is not null)
|
||||||
|
{
|
||||||
|
issues.Add(
|
||||||
|
$"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsWinnerReadiness && categoryIds.Length > 0)
|
if (needsWinnerReadiness && categoryIds.Length > 0)
|
||||||
{
|
{
|
||||||
|
if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now))
|
||||||
|
{
|
||||||
|
issues.Add("Die Award Show liegt noch nicht in der Vergangenheit.");
|
||||||
|
}
|
||||||
|
|
||||||
var categoriesWithResults = await db.Results
|
var categoriesWithResults = await db.Results
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
{
|
{
|
||||||
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var resultSnapshots = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => new WinnerReadinessSnapshot(
|
||||||
|
item.CategoryId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
|
item.Candidate.DisplayName,
|
||||||
|
item.Candidate.ChannelSlug,
|
||||||
|
item.Candidate.ClipCompilationUrl))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
||||||
|
{
|
||||||
|
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
||||||
|
if (missingWinnerClipCount > 0)
|
||||||
|
{
|
||||||
|
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||||
|
{
|
||||||
|
var winnerOverflow = resultSnapshots
|
||||||
|
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Count = group.Count(),
|
||||||
|
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||||
|
})
|
||||||
|
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (winnerOverflow is not null)
|
||||||
|
{
|
||||||
|
issues.Add(
|
||||||
|
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return issues.ToArray();
|
return issues.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug)
|
||||||
|
{
|
||||||
|
if (streamerIdentityId.HasValue)
|
||||||
|
{
|
||||||
|
return $"identity:{streamerIdentityId.Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
||||||
{
|
{
|
||||||
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
||||||
@@ -364,6 +484,21 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
|
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.ViewerRangeMin is < 0 or > 100000)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ViewerRangeMax is < 0 or > 100000)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." });
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
|
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
|
||||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
||||||
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
||||||
@@ -56,11 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.Select(item => new
|
.Select(item => new
|
||||||
{
|
{
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
item.Candidate.DisplayName,
|
item.Candidate.DisplayName,
|
||||||
item.Candidate.ChannelSlug,
|
item.Candidate.ChannelSlug,
|
||||||
})
|
})
|
||||||
.ToArrayAsync(context.RequestAborted);
|
.ToArrayAsync(context.RequestAborted);
|
||||||
var existingWinnerCount = existingWinnerIdentities.Count(item =>
|
var existingWinnerCount = existingWinnerIdentities.Count(item =>
|
||||||
|
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
|
||||||
|
||
|
||||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
|
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
|
||||||
|
|
||||||
if (existingWinnerCount >= winnerPlacementsRule.Limit)
|
if (existingWinnerCount >= winnerPlacementsRule.Limit)
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
.WithName("UpdateAdminOptionalFeatureSettings")
|
.WithName("UpdateAdminOptionalFeatureSettings")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
group.MapGet("/tracking-rules", GetTrackingRules)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
|
.WithName("GetAdminTrackingRules")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/tracking-rules", UpdateTrackingRules)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
|
.WithName("UpdateAdminTrackingRules")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/tracking-rules/source", UpdateTrackingSource)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
|
.WithName("UpdateAdminTrackingSource")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
|
.WithName("UpdateAdminTrackingReviewNotes")
|
||||||
|
.WithOpenApi();
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.HostDisplayName,
|
settings.HostDisplayName,
|
||||||
settings.HostTagline,
|
settings.HostTagline,
|
||||||
settings.NewsletterUrl,
|
settings.NewsletterUrl,
|
||||||
|
settings.ShareXUrl,
|
||||||
|
settings.ShareDiscordUrl,
|
||||||
settings.PrivacyEmail,
|
settings.PrivacyEmail,
|
||||||
settings.PrivacyPolicyContent,
|
settings.PrivacyPolicyContent,
|
||||||
settings.PrivacyPolicyUpdatedBy,
|
settings.PrivacyPolicyUpdatedBy,
|
||||||
@@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.ShowactsUrl,
|
settings.ShowactsUrl,
|
||||||
settings.ShowactsContent,
|
settings.ShowactsContent,
|
||||||
SeasonMappings.ReadSocialLinks(settings),
|
SeasonMappings.ReadSocialLinks(settings),
|
||||||
SeasonMappings.ReadFaqItems(settings)));
|
SeasonMappings.ReadFaqItems(settings),
|
||||||
|
settings.ShowactFormSchemaJson ?? "[]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSiteSettings(
|
private static async Task<IResult> UpdateSiteSettings(
|
||||||
@@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||||
settings.HostTagline = request.HostTagline.Trim();
|
settings.HostTagline = request.HostTagline.Trim();
|
||||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||||
|
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
||||||
|
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
||||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||||
@@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.ShowactsContent = request.ShowactsContent.Trim();
|
settings.ShowactsContent = request.ShowactsContent.Trim();
|
||||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||||
|
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
|
||||||
|
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
socialLinks = [];
|
socialLinks = [];
|
||||||
|
|
||||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||||
|
|| !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage)
|
||||||
|
|| !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out errorMessage)
|
||||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
||||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|
||||||
@@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
normalizedUrls = new PublicSiteUrlSettings
|
normalizedUrls = new PublicSiteUrlSettings
|
||||||
{
|
{
|
||||||
NewsletterUrl = newsletterUrl,
|
NewsletterUrl = newsletterUrl,
|
||||||
|
ShareXUrl = shareXUrl,
|
||||||
|
ShareDiscordUrl = shareDiscordUrl,
|
||||||
ImprintUrl = imprintUrl,
|
ImprintUrl = imprintUrl,
|
||||||
ContactUrl = contactUrl,
|
ContactUrl = contactUrl,
|
||||||
SponsorsUrl = sponsorsUrl,
|
SponsorsUrl = sponsorsUrl,
|
||||||
@@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
private sealed class PublicSiteUrlSettings
|
private sealed class PublicSiteUrlSettings
|
||||||
{
|
{
|
||||||
public string NewsletterUrl { get; set; } = string.Empty;
|
public string NewsletterUrl { get; set; } = string.Empty;
|
||||||
|
public string ShareXUrl { get; set; } = string.Empty;
|
||||||
|
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||||
public string ImprintUrl { get; set; } = string.Empty;
|
public string ImprintUrl { get; set; } = string.Empty;
|
||||||
public string ContactUrl { get; set; } = string.Empty;
|
public string ContactUrl { get; set; } = string.Empty;
|
||||||
public string SponsorsUrl { get; set; } = string.Empty;
|
public string SponsorsUrl { get; set; } = string.Empty;
|
||||||
@@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetTrackingRules(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(ToTrackingRulesResponse(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateTrackingRules(
|
||||||
|
HttpContext context,
|
||||||
|
UpdateTrackingRulesRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService,
|
||||||
|
NominationTrackingReviewService trackingReviewService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = ToTrackingRulesResponse(settings);
|
||||||
|
var applyResult = ApplyTrackingRulesRequest(settings, request);
|
||||||
|
if (applyResult is not null)
|
||||||
|
{
|
||||||
|
return applyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||||
|
|
||||||
|
var after = ToTrackingRulesResponse(settings);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"tracking-rules.update",
|
||||||
|
"site-settings",
|
||||||
|
settings.Id.ToString(),
|
||||||
|
"Tracking Rules wurden aktualisiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl,
|
||||||
|
manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes,
|
||||||
|
importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled),
|
||||||
|
optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled),
|
||||||
|
flagCount = after.Flags.Count(item => item.Enabled),
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateTrackingSource(
|
||||||
|
HttpContext context,
|
||||||
|
UpdateTrackingSourceRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService,
|
||||||
|
NominationTrackingReviewService trackingReviewService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = errorMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = ToTrackingRulesResponse(settings);
|
||||||
|
var rules = TrackingRulesSettings.Read(settings);
|
||||||
|
var updatedRules = rules with
|
||||||
|
{
|
||||||
|
Source = new TrackingSourceSetting(
|
||||||
|
TrackingRulesSettings.ProviderKey,
|
||||||
|
normalizedBaseUrl,
|
||||||
|
request.Source?.NotesSummary ?? rules.Source.NotesSummary,
|
||||||
|
request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview),
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||||
|
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules);
|
||||||
|
|
||||||
|
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||||
|
|
||||||
|
var after = ToTrackingRulesResponse(settings);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"tracking-source.update",
|
||||||
|
"site-settings",
|
||||||
|
settings.Id.ToString(),
|
||||||
|
"Tracking Source wurde aktualisiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
beforeBaseUrl = before.Source.BaseUrl,
|
||||||
|
afterBaseUrl = after.Source.BaseUrl,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateTrackingReviewNotes(
|
||||||
|
HttpContext context,
|
||||||
|
UpdateTrackingReviewNotesRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var beforeNotes = settings.TrackingReviewNotes ?? string.Empty;
|
||||||
|
var before = ToTrackingRulesResponse(settings);
|
||||||
|
var rules = TrackingRulesSettings.Read(settings);
|
||||||
|
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||||
|
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with
|
||||||
|
{
|
||||||
|
Source = rules.Source with
|
||||||
|
{
|
||||||
|
ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var after = ToTrackingRulesResponse(settings);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"tracking-review-notes.update",
|
||||||
|
"site-settings",
|
||||||
|
settings.Id.ToString(),
|
||||||
|
"Tracking Review Notes wurden aktualisiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
beforeLength = beforeNotes.Length,
|
||||||
|
afterLength = settings.TrackingReviewNotes.Length,
|
||||||
|
beforeShowInReview = before.Source.ShowManualReviewNotesInReview,
|
||||||
|
afterShowInReview = after.Source.ShowManualReviewNotesInReview,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(after);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
UpdateOptionalFeatureSettingsRequest request,
|
UpdateOptionalFeatureSettingsRequest request,
|
||||||
@@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var before = ToOptionalFeatureSettingsResponse(settings);
|
var before = ToOptionalFeatureSettingsResponse(settings);
|
||||||
|
var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt);
|
||||||
|
if (scheduleValidationError is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = scheduleValidationError });
|
||||||
|
}
|
||||||
|
|
||||||
var disabledMessage = NormalizeOptionalFeatureText(
|
var disabledMessage = NormalizeOptionalFeatureText(
|
||||||
request.ClipSubmissionDisabledMessage,
|
request.ClipSubmissionDisabledMessage,
|
||||||
FallbackClipSubmissionDisabledMessage,
|
FallbackClipSubmissionDisabledMessage,
|
||||||
@@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
||||||
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
||||||
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
||||||
|
settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt;
|
||||||
|
settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt;
|
||||||
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
||||||
settings.SponsorsVisible = request.SponsorsVisible;
|
settings.SponsorsVisible = request.SponsorsVisible;
|
||||||
|
|
||||||
@@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
? FallbackClipSubmissionDisabledMessage
|
? FallbackClipSubmissionDisabledMessage
|
||||||
: settings.ClipSubmissionDisabledMessage,
|
: settings.ClipSubmissionDisabledMessage,
|
||||||
settings.ShowactApplicationsEnabled,
|
settings.ShowactApplicationsEnabled,
|
||||||
|
settings.ShowactApplicationStartsAt,
|
||||||
|
settings.ShowactApplicationEndsAt,
|
||||||
|
ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||||
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
||||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||||
: settings.ShowactApplicationDisabledMessage,
|
: settings.ShowactApplicationDisabledMessage,
|
||||||
@@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
||||||
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
||||||
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
||||||
|
AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt);
|
||||||
|
AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt);
|
||||||
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
||||||
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
||||||
return changes.ToArray();
|
return changes.ToArray();
|
||||||
@@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||||
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||||
usesDatabaseDemo,
|
usesDatabaseDemo,
|
||||||
@@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
twitchSettings.ClientSecretSet,
|
twitchSettings.ClientSecretSet,
|
||||||
twitchSettings.RedirectUri,
|
twitchSettings.RedirectUri,
|
||||||
twitchSettings.Scope,
|
twitchSettings.Scope,
|
||||||
|
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||||
settings.MaintenanceModeEnabled,
|
settings.MaintenanceModeEnabled,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||||
@@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
||||||
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
||||||
var twitchScope = request.TwitchScope.Trim();
|
var twitchScope = request.TwitchScope.Trim();
|
||||||
|
if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours);
|
||||||
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||||
|
|
||||||
@@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.TwitchClientSecret,
|
settings.TwitchClientSecret,
|
||||||
settings.TwitchRedirectUri,
|
settings.TwitchRedirectUri,
|
||||||
settings.TwitchScope);
|
settings.TwitchScope);
|
||||||
|
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
|
||||||
|
|
||||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||||
? newPassword
|
? newPassword
|
||||||
@@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
"operational-settings.update",
|
"operational-settings.update",
|
||||||
"site-settings",
|
"site-settings",
|
||||||
settings.Id.ToString(),
|
settings.Id.ToString(),
|
||||||
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
|
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
settings.DemoLoginEnabled,
|
settings.DemoLoginEnabled,
|
||||||
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
||||||
|
settings.SessionIdleTimeoutHours,
|
||||||
settings.MaintenanceModeEnabled,
|
settings.MaintenanceModeEnabled,
|
||||||
changes,
|
changes,
|
||||||
},
|
},
|
||||||
@@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
HasEffectiveTwitchClientSecret(settings, configuration),
|
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||||
settings.TwitchRedirectUri,
|
settings.TwitchRedirectUri,
|
||||||
settings.TwitchScope,
|
settings.TwitchScope,
|
||||||
|
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||||
settings.MaintenanceModeEnabled,
|
settings.MaintenanceModeEnabled,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||||
@@ -576,6 +781,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
||||||
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
||||||
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
||||||
|
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
|
||||||
|
|
||||||
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
||||||
{
|
{
|
||||||
@@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
bool TwitchClientSecretSet,
|
bool TwitchClientSecretSet,
|
||||||
string TwitchRedirectUri,
|
string TwitchRedirectUri,
|
||||||
string TwitchScope,
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
@@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
string RedirectUri,
|
string RedirectUri,
|
||||||
string Scope,
|
string Scope,
|
||||||
bool Configured);
|
bool Configured);
|
||||||
|
|
||||||
|
private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = errorMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentRules = TrackingRulesSettings.Read(settings);
|
||||||
|
var configuration = new TrackingRulesConfiguration(
|
||||||
|
new TrackingSourceSetting(
|
||||||
|
TrackingRulesSettings.ProviderKey,
|
||||||
|
normalizedBaseUrl,
|
||||||
|
request.Source?.NotesSummary ?? currentRules.Source.NotesSummary,
|
||||||
|
request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview),
|
||||||
|
(request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||||
|
(request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||||
|
(request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray());
|
||||||
|
|
||||||
|
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration);
|
||||||
|
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||||
|
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings)
|
||||||
|
{
|
||||||
|
var rules = TrackingRulesSettings.Read(settings);
|
||||||
|
return new AdminTrackingRulesResponse(
|
||||||
|
new AdminTrackingSourceDto(
|
||||||
|
rules.Source.ProviderKey,
|
||||||
|
"TwitchTracker Basic API",
|
||||||
|
TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl),
|
||||||
|
rules.Source.NotesSummary,
|
||||||
|
rules.Source.ShowManualReviewNotesInReview),
|
||||||
|
rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||||
|
rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||||
|
rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(),
|
||||||
|
settings.TrackingReviewNotes ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) =>
|
||||||
|
new(
|
||||||
|
rule.Key,
|
||||||
|
rule.Label,
|
||||||
|
rule.Enabled,
|
||||||
|
rule.SourceSupport,
|
||||||
|
rule.Description,
|
||||||
|
rule.RequiredForAutoClassification,
|
||||||
|
rule.ShowInReview,
|
||||||
|
rule.ShowInAdminSummary,
|
||||||
|
rule.ManualOverrideAllowed,
|
||||||
|
rule.WindowKey,
|
||||||
|
rule.AutoSupportedWindowKeys,
|
||||||
|
rule.ProviderFieldKey,
|
||||||
|
rule.TopCount,
|
||||||
|
rule.MinPrimaryCategorySharePercent,
|
||||||
|
rule.MinPrimaryCategoryHours,
|
||||||
|
rule.MaxDistinctCategoriesBeforeFlag,
|
||||||
|
rule.IgnoredCategories,
|
||||||
|
rule.MatchAwardCategoryAgainstTopCategories,
|
||||||
|
rule.FlagIfAwardCategoryNotInTopX,
|
||||||
|
rule.FlagIfCategorySpreadTooWide,
|
||||||
|
rule.FlagIfNoCategoryContextAvailable,
|
||||||
|
rule.MinValue,
|
||||||
|
rule.MaxValue);
|
||||||
|
|
||||||
|
private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) =>
|
||||||
|
new(
|
||||||
|
rule.Key,
|
||||||
|
rule.Label,
|
||||||
|
rule.Enabled,
|
||||||
|
rule.SourceSupport,
|
||||||
|
rule.Description,
|
||||||
|
rule.RequiredForAutoClassification,
|
||||||
|
rule.ShowInReview,
|
||||||
|
rule.ShowInAdminSummary,
|
||||||
|
rule.ManualOverrideAllowed,
|
||||||
|
TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d),
|
||||||
|
(rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||||
|
string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(),
|
||||||
|
rule.TopCount,
|
||||||
|
rule.MinPrimaryCategorySharePercent,
|
||||||
|
rule.MinPrimaryCategoryHours,
|
||||||
|
rule.MaxDistinctCategoriesBeforeFlag,
|
||||||
|
(rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||||
|
rule.MatchAwardCategoryAgainstTopCategories,
|
||||||
|
rule.FlagIfAwardCategoryNotInTopX,
|
||||||
|
rule.FlagIfCategorySpreadTooWide,
|
||||||
|
rule.FlagIfNoCategoryContextAvailable,
|
||||||
|
rule.MinValue,
|
||||||
|
rule.MaxValue);
|
||||||
|
|
||||||
|
private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) =>
|
||||||
|
new(
|
||||||
|
rule.Key,
|
||||||
|
rule.Label,
|
||||||
|
rule.Enabled,
|
||||||
|
rule.Severity,
|
||||||
|
rule.Description,
|
||||||
|
rule.AutoTriggerEnabled,
|
||||||
|
rule.RequiresManualReview,
|
||||||
|
rule.BlocksApproval,
|
||||||
|
rule.AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) =>
|
||||||
|
new(
|
||||||
|
rule.Key,
|
||||||
|
rule.Label,
|
||||||
|
rule.Enabled,
|
||||||
|
rule.Severity,
|
||||||
|
rule.Description,
|
||||||
|
rule.AutoTriggerEnabled,
|
||||||
|
rule.RequiresManualReview,
|
||||||
|
rule.BlocksApproval,
|
||||||
|
rule.AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage)
|
||||||
|
{
|
||||||
|
normalizedValue = string.Empty;
|
||||||
|
errorMessage = string.Empty;
|
||||||
|
var trimmed = (rawValue ?? string.Empty).Trim();
|
||||||
|
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,33 +8,41 @@ namespace Backend.Endpoints;
|
|||||||
|
|
||||||
public static partial class AdminSeasonManagementEndpoints
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
{
|
{
|
||||||
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
|
private static async Task<IResult> GetWorkflowRules(int seasonId, AwardsDbContext db)
|
||||||
{
|
{
|
||||||
var settings = await db.SiteSettings
|
var season = await db.Seasons
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||||
if (settings is null)
|
if (season is null)
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
|
||||||
|
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(season, settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateWorkflowRules(
|
private static async Task<IResult> UpdateWorkflowRules(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
UpdateWorkflowRulesRequest request,
|
UpdateWorkflowRulesRequest request,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IAdminAuditService adminAuditService)
|
IAdminAuditService adminAuditService)
|
||||||
{
|
{
|
||||||
var session = AdminEndpointConventions.CurrentSession(context);
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||||
if (settings is null)
|
if (season is null)
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var before = WorkflowRuleSettings.Read(settings);
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
|
var before = WorkflowRuleSettings.Read(season, settings);
|
||||||
var mergedRules = WorkflowRuleSettings.Defaults
|
var mergedRules = WorkflowRuleSettings.Defaults
|
||||||
.Select(defaultRule =>
|
.Select(defaultRule =>
|
||||||
{
|
{
|
||||||
@@ -51,8 +59,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
})
|
})
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||||
var after = WorkflowRuleSettings.Read(settings);
|
var after = WorkflowRuleSettings.Read(season, settings);
|
||||||
var changes = after
|
var changes = after
|
||||||
.Select(rule =>
|
.Select(rule =>
|
||||||
{
|
{
|
||||||
@@ -72,10 +80,10 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
"workflow-rules.update",
|
"workflow-rules.update",
|
||||||
"site-settings",
|
"season",
|
||||||
settings.Id.ToString(),
|
season.Id.ToString(),
|
||||||
"Workflow-Regeln wurden aktualisiert.",
|
$"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.",
|
||||||
new { changes },
|
new { seasonId = season.Id, season.Year, changes },
|
||||||
RequestMetadataReader.Read(context));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public static partial class AuthEndpoints
|
|||||||
{
|
{
|
||||||
private static async Task<IResult> DemoLogin(
|
private static async Task<IResult> DemoLogin(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
|
IHostEnvironment environment,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
DemoLoginRequest request,
|
DemoLoginRequest request,
|
||||||
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
|
|||||||
var password = request.Password ?? string.Empty;
|
var password = request.Password ?? string.Empty;
|
||||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
var databaseDemoConfigured = settings is not null
|
var databaseDemoConfigured = settings is not null
|
||||||
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
|
&& settings.DemoLoginManagedByDatabase;
|
||||||
|
|
||||||
string twitchUserId;
|
string twitchUserId;
|
||||||
string displayName;
|
string displayName;
|
||||||
bool credentialsMatch;
|
bool credentialsMatch;
|
||||||
|
var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration);
|
||||||
|
var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||||
|
var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||||
|
var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||||
|
var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||||
|
|
||||||
if (databaseDemoConfigured && settings is not null)
|
if (databaseDemoConfigured && settings is not null)
|
||||||
{
|
{
|
||||||
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
|
|||||||
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||||
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||||
displayName = settings.DemoLoginDisplayName.Trim();
|
displayName = settings.DemoLoginDisplayName.Trim();
|
||||||
|
|
||||||
|
if (!credentialsMatch
|
||||||
|
&& environment.IsDevelopment()
|
||||||
|
&& IsDemoLoginEnabled(configuration)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||||
|
{
|
||||||
|
credentialsMatch = LoginMatchesIdentifier(
|
||||||
|
login,
|
||||||
|
fallbackConfiguredLogin,
|
||||||
|
fallbackConfiguredEmail,
|
||||||
|
fallbackConfiguredTwitchUserId,
|
||||||
|
fallbackConfiguredDisplayName)
|
||||||
|
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||||
|
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||||
|
displayName = fallbackConfiguredDisplayName.Trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var configuredLogin = ReadDemoLoginIdentifier(configuration);
|
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||||
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||||
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||||
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||||
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(configuredLogin)
|
|
||||||
|| string.IsNullOrWhiteSpace(configuredPassword)
|
|
||||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
|
||||||
|| string.IsNullOrWhiteSpace(displayName))
|
|
||||||
{
|
{
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
new { message = "Demo login is not fully configured." },
|
new { message = "Demo login is not fully configured." },
|
||||||
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
|
|||||||
|
|
||||||
credentialsMatch = LoginMatchesIdentifier(
|
credentialsMatch = LoginMatchesIdentifier(
|
||||||
login,
|
login,
|
||||||
configuredLogin,
|
fallbackConfiguredLogin,
|
||||||
configuredEmail,
|
fallbackConfiguredEmail,
|
||||||
twitchUserId,
|
fallbackConfiguredTwitchUserId,
|
||||||
displayName)
|
fallbackConfiguredDisplayName)
|
||||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
|
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||||
twitchUserId = twitchUserId.Trim();
|
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||||
displayName = displayName.Trim();
|
displayName = fallbackConfiguredDisplayName.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!credentialsMatch)
|
if (!credentialsMatch)
|
||||||
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
|
|||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||||
|
|||||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
|||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public static partial class AuthEndpoints
|
|||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||||
@@ -36,6 +36,7 @@ public static partial class AuthEndpoints
|
|||||||
|
|
||||||
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
UserSession session,
|
UserSession session,
|
||||||
bool mustChangePassword = false,
|
bool mustChangePassword = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
|
|||||||
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||||
var sessionRole = teamMember?.Role ?? session.Role;
|
var sessionRole = teamMember?.Role ?? session.Role;
|
||||||
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||||
|
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
|
||||||
return new(
|
return new(
|
||||||
session.SessionToken,
|
session.SessionToken,
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
teamMember?.DisplayName ?? session.DisplayName,
|
teamMember?.DisplayName ?? session.DisplayName,
|
||||||
AdminRoles.Normalize(sessionRole),
|
AdminRoles.Normalize(sessionRole),
|
||||||
permissionKeys,
|
permissionKeys,
|
||||||
|
sessionIdleTimeoutHours,
|
||||||
teamMember?.MustChangePassword ?? mustChangePassword,
|
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||||
teamMember?.Login,
|
teamMember?.Login,
|
||||||
teamMember?.BoundTwitchUserId,
|
teamMember?.BoundTwitchUserId,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public static partial class AuthEndpoints
|
|||||||
context.RequestAborted);
|
context.RequestAborted);
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ChangePassword(
|
private static async Task<IResult> ChangePassword(
|
||||||
@@ -92,7 +92,7 @@ public static partial class AuthEndpoints
|
|||||||
session.Role = AdminRoles.Normalize(member.Role);
|
session.Role = AdminRoles.Normalize(member.Role);
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildTeamSessionId(string login) =>
|
private static string BuildTeamSessionId(string login) =>
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
|
|||||||
return Results.Ok(new TwitchBindingDisconnectResponse(
|
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||||
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
|
|||||||
currentSessionUsesBoundTwitch,
|
currentSessionUsesBoundTwitch,
|
||||||
currentSessionUsesBoundTwitch
|
currentSessionUsesBoundTwitch
|
||||||
? null
|
? null
|
||||||
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Backend.Common;
|
using Backend.Common;
|
||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
@@ -8,6 +11,8 @@ namespace Backend.Endpoints;
|
|||||||
|
|
||||||
public static partial class PublicEndpoints
|
public static partial class PublicEndpoints
|
||||||
{
|
{
|
||||||
|
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
|
||||||
|
|
||||||
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||||
{
|
{
|
||||||
var season = await db.Seasons
|
var season = await db.Seasons
|
||||||
@@ -45,13 +50,19 @@ public static partial class PublicEndpoints
|
|||||||
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
};
|
||||||
|
|
||||||
private static async Task<IResult> CreateShowactApplication(
|
private static async Task<IResult> CreateShowactApplication(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
CreateShowactApplicationRequest request,
|
CreateShowactApplicationRequest request,
|
||||||
AwardsDbContext db)
|
AwardsDbContext db)
|
||||||
{
|
{
|
||||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
if (settings is null || !settings.ShowactApplicationsEnabled)
|
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new
|
return Results.BadRequest(new
|
||||||
{
|
{
|
||||||
@@ -67,55 +78,137 @@ public static partial class PublicEndpoints
|
|||||||
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
||||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
|
||||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
|
||||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
|
||||||
var description = NormalizePublicText(request.Description, 1000);
|
|
||||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
|
||||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(artistName))
|
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
||||||
|
var hasDynamicForm = schema.Count > 0;
|
||||||
|
|
||||||
|
if (hasDynamicForm)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
Dictionary<string, string> responses;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson)
|
||||||
|
? new Dictionary<string, string>()
|
||||||
|
: JsonSerializer.Deserialize<Dictionary<string, string>>(request.FieldResponsesJson, ShowactJsonOptions) ?? new();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
MergeLegacyShowactFieldsIntoResponses(schema, responses, request);
|
||||||
|
|
||||||
|
var dynamicValidationError = ValidateShowactResponses(schema, responses);
|
||||||
|
if (dynamicValidationError is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = dynamicValidationError });
|
||||||
|
}
|
||||||
|
|
||||||
|
var artistNameField = schema.FirstOrDefault(f => f.IsArtistName);
|
||||||
|
var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt";
|
||||||
|
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||||
|
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||||
|
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||||
|
var description = NormalizePublicText(request.Description, 1000);
|
||||||
|
var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000);
|
||||||
|
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||||
|
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||||
|
var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions);
|
||||||
|
|
||||||
|
var metadata = RequestMetadataReader.Read(context);
|
||||||
|
var application = new ShowactApplication
|
||||||
|
{
|
||||||
|
SeasonId = season.Id,
|
||||||
|
ArtistName = artistName[..Math.Min(artistName.Length, 120)],
|
||||||
|
ContactEmail = contactEmail,
|
||||||
|
ContactDiscord = contactDiscord,
|
||||||
|
PlatformUrl = platformUrl,
|
||||||
|
PerformanceType = performanceType,
|
||||||
|
Description = description,
|
||||||
|
TechnicalNotes = technicalNotes,
|
||||||
|
ReferenceUrl = referenceUrl,
|
||||||
|
FieldResponsesJson = fieldResponsesJson,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedFromIp = metadata.ClientIp,
|
||||||
|
UserAgent = metadata.UserAgent,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.ShowactApplications.Add(application);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
// Legacy fixed-fields path
|
||||||
|
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||||
|
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||||
|
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||||
|
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||||
|
var description = NormalizePublicText(request.Description, 1000);
|
||||||
|
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||||
|
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(artistName))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsBlankOrValidEmail(contactEmail))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = RequestMetadataReader.Read(context);
|
||||||
|
var application = new ShowactApplication
|
||||||
|
{
|
||||||
|
SeasonId = season.Id,
|
||||||
|
ArtistName = artistName,
|
||||||
|
ContactEmail = contactEmail,
|
||||||
|
ContactDiscord = contactDiscord,
|
||||||
|
PlatformUrl = platformUrl,
|
||||||
|
PerformanceType = performanceType,
|
||||||
|
Description = description,
|
||||||
|
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||||
|
ReferenceUrl = referenceUrl,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedFromIp = metadata.ClientIp,
|
||||||
|
UserAgent = metadata.UserAgent,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ShowactApplications.Add(application);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
private sealed class ShowactFieldDefinition
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
}
|
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||||
|
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||||
var metadata = RequestMetadataReader.Read(context);
|
[JsonPropertyName("required")] public bool Required { get; set; }
|
||||||
var application = new ShowactApplication
|
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
|
||||||
{
|
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
|
||||||
SeasonId = season.Id,
|
|
||||||
ArtistName = artistName,
|
|
||||||
ContactEmail = contactEmail,
|
|
||||||
ContactDiscord = contactDiscord,
|
|
||||||
PlatformUrl = platformUrl,
|
|
||||||
PerformanceType = performanceType,
|
|
||||||
Description = description,
|
|
||||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
|
||||||
ReferenceUrl = referenceUrl,
|
|
||||||
Status = "pending",
|
|
||||||
CreatedFromIp = metadata.ClientIp,
|
|
||||||
UserAgent = metadata.UserAgent,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
|
||||||
};
|
|
||||||
|
|
||||||
db.ShowactApplications.Add(application);
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
|
||||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizePublicText(string? value, int maxLength)
|
private static string NormalizePublicText(string? value, int maxLength)
|
||||||
@@ -128,4 +221,167 @@ public static partial class PublicEndpoints
|
|||||||
string.IsNullOrWhiteSpace(value)
|
string.IsNullOrWhiteSpace(value)
|
||||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||||
|
|
||||||
|
private static bool IsBlankOrValidEmail(string value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
|
||||||
|
|
||||||
|
private static bool IsBlankOrValidJsonObject(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(value);
|
||||||
|
return document.RootElement.ValueKind == JsonValueKind.Object;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
|
||||||
|
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
|
||||||
|
.ToList() ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateShowactResponses(
|
||||||
|
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||||
|
IDictionary<string, string> responses)
|
||||||
|
{
|
||||||
|
foreach (var field in schema)
|
||||||
|
{
|
||||||
|
var value = responses.TryGetValue(field.Id, out var rawValue)
|
||||||
|
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
|
||||||
|
: string.Empty;
|
||||||
|
responses[field.Id] = value;
|
||||||
|
|
||||||
|
if (field.Required && string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"Bitte bestaetige: {field.Label}"
|
||||||
|
: $"{field.Label} ist erforderlich.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
|
||||||
|
{
|
||||||
|
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
|
||||||
|
{
|
||||||
|
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
|
||||||
|
{
|
||||||
|
if (field.MaxLength > 0)
|
||||||
|
{
|
||||||
|
return Math.Min(field.MaxLength, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeLegacyShowactFieldsIntoResponses(
|
||||||
|
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||||
|
IDictionary<string, string> responses,
|
||||||
|
CreateShowactApplicationRequest request)
|
||||||
|
{
|
||||||
|
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
|
||||||
|
MergeResponseValue(artistField, request.ArtistName, responses, 120);
|
||||||
|
|
||||||
|
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
|
||||||
|
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
|
||||||
|
|
||||||
|
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
|
||||||
|
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
|
||||||
|
|
||||||
|
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
|
||||||
|
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
|
||||||
|
|
||||||
|
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
|
||||||
|
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
|
||||||
|
|
||||||
|
var performanceField = schema.FirstOrDefault(field =>
|
||||||
|
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|
||||||
|
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
|
||||||
|
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
|
||||||
|
|
||||||
|
var descriptionField = schema.FirstOrDefault(field =>
|
||||||
|
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|
||||||
|
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
|
||||||
|
MergeResponseValue(descriptionField, request.Description, responses, 1000);
|
||||||
|
|
||||||
|
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
|
||||||
|
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeResponseValue(
|
||||||
|
ShowactFieldDefinition? field,
|
||||||
|
string? requestValue,
|
||||||
|
IDictionary<string, string> responses,
|
||||||
|
int maxLength)
|
||||||
|
{
|
||||||
|
if (field is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizePublicText(requestValue, maxLength);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
responses[field.Id] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
|
||||||
|
{
|
||||||
|
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
|
||||||
|
return needles.Any(haystack.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
|
||||||
|
{
|
||||||
|
var haystack = field.Id.ToLowerInvariant();
|
||||||
|
return needles.Any(haystack.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
|
||||||
|
{
|
||||||
|
var haystack = field.Label.ToLowerInvariant();
|
||||||
|
return needles.Any(haystack.Contains);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ public static partial class PublicEndpoints
|
|||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IUserSessionService userSessionService,
|
IUserSessionService userSessionService,
|
||||||
IRiskFlagService riskFlagService,
|
IRiskFlagService riskFlagService,
|
||||||
IRiskRuleService riskRuleService)
|
IRiskRuleService riskRuleService,
|
||||||
|
NominationEnrichmentService nominationEnrichmentService)
|
||||||
{
|
{
|
||||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
var submittedNominations = NormalizeSubmittedNominations(request);
|
||||||
|
|
||||||
if (submittedNominations.Length is 0 or > 3)
|
if (submittedNominations.Length == 0)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
|
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
||||||
@@ -35,7 +36,7 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var distinctStreamUrls = submittedNominations
|
var distinctStreamUrls = submittedNominations
|
||||||
.Select(item => item.StreamUrl)
|
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -65,16 +66,37 @@ public static partial class PublicEndpoints
|
|||||||
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var category = await db.Categories
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||||
.Include(item => item.Season)
|
|
||||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
|
||||||
|
|
||||||
if (category is null)
|
if (season is null)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
return Results.BadRequest(new { message = "The selected season does not exist." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
|
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted);
|
||||||
|
if (string.IsNullOrWhiteSpace(categoryGroupName))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupCategories = await db.Categories
|
||||||
|
.Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
if (groupCategories.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories);
|
||||||
|
if (submittedNominations.Length > maxNomineesPerUser)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||||
if (nominationSeasonResolution.Result is not null)
|
if (nominationSeasonResolution.Result is not null)
|
||||||
{
|
{
|
||||||
return nominationSeasonResolution.Result;
|
return nominationSeasonResolution.Result;
|
||||||
@@ -89,15 +111,16 @@ public static partial class PublicEndpoints
|
|||||||
var submitterId = submitterIdResult.SubmitterId!;
|
var submitterId = submitterIdResult.SubmitterId!;
|
||||||
var requestMetadata = RequestMetadataReader.Read(context);
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||||
item.SeasonId == category.SeasonId
|
item.SeasonId == season.Id
|
||||||
&& item.CategoryId == category.Id
|
&& item.CategoryGroupName == categoryGroupName
|
||||||
&& item.SubmittedByTwitchId == submitterId
|
&& item.SubmittedByTwitchId == submitterId
|
||||||
&& item.Status == "pending");
|
&& item.Status == "pending");
|
||||||
|
|
||||||
var records = submittedNominations.Select(nomination => new Nomination
|
var records = submittedNominations.Select(nomination => new Nomination
|
||||||
{
|
{
|
||||||
SeasonId = category.SeasonId,
|
SeasonId = season.Id,
|
||||||
CategoryId = category.Id,
|
CategoryId = null,
|
||||||
|
CategoryGroupName = categoryGroupName,
|
||||||
SubmittedByTwitchId = submitterId,
|
SubmittedByTwitchId = submitterId,
|
||||||
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||||
@@ -106,6 +129,11 @@ public static partial class PublicEndpoints
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
await db.Nominations.AddRangeAsync(records);
|
await db.Nominations.AddRangeAsync(records);
|
||||||
|
|
||||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||||
@@ -126,21 +154,21 @@ public static partial class PublicEndpoints
|
|||||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||||
{
|
{
|
||||||
await riskFlagService.AddIfMissingAsync(
|
await riskFlagService.AddIfMissingAsync(
|
||||||
category.SeasonId,
|
season.Id,
|
||||||
submitterId,
|
submitterId,
|
||||||
"nomination",
|
"nomination",
|
||||||
"resubmitted_nomination",
|
"resubmitted_nomination",
|
||||||
resubmittedNominationRule.Severity,
|
resubmittedNominationRule.Severity,
|
||||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||||
context.RequestAborted);
|
context.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||||
{
|
{
|
||||||
await riskFlagService.AddIfMissingAsync(
|
await riskFlagService.AddIfMissingAsync(
|
||||||
category.SeasonId,
|
season.Id,
|
||||||
submitterId,
|
submitterId,
|
||||||
"nomination",
|
"nomination",
|
||||||
"rapid_nomination_burst",
|
"rapid_nomination_burst",
|
||||||
@@ -152,7 +180,7 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||||
@@ -191,4 +219,44 @@ public static partial class PublicEndpoints
|
|||||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeNominationUrlForCompare(string value) =>
|
||||||
|
value.Trim().TrimEnd('/').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
|
||||||
|
{
|
||||||
|
var configuredLimit = groupCategories
|
||||||
|
.Select(item => item.MaxNomineesPerUser)
|
||||||
|
.Where(value => value > 0)
|
||||||
|
.DefaultIfEmpty(3)
|
||||||
|
.Max();
|
||||||
|
|
||||||
|
return Math.Clamp(configuredLimit, 1, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ResolveCategoryGroupNameAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int seasonId,
|
||||||
|
CreateNominationRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var categoryGroupName = request.CategoryGroupName?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(categoryGroupName))
|
||||||
|
{
|
||||||
|
return await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
|
||||||
|
.Select(item => item.GroupName)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.CategoryId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
|
||||||
|
.Select(item => item.GroupName)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ public static partial class PublicEndpoints
|
|||||||
{
|
{
|
||||||
return Results.Problem("Site settings are missing.");
|
return Results.Problem("Site settings are missing.");
|
||||||
}
|
}
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
|
||||||
|
|
||||||
|
var canExposeCurrentSeasonWinners = CanExposeCurrentSeasonWinners(season.CurrentPhase);
|
||||||
|
|
||||||
var winnerPreviewRows = await db.Results
|
var winnerPreviewRows = await db.Results
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(result => result.Season)
|
.Include(result => result.Season)
|
||||||
.Include(result => result.Candidate)
|
.Include(result => result.Candidate)
|
||||||
.Where(result => result.Season.Year < season.Year)
|
.Where(result =>
|
||||||
|
result.Season.Year < season.Year
|
||||||
|
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||||
.OrderByDescending(result => result.Season.Year)
|
.OrderByDescending(result => result.Season.Year)
|
||||||
.ThenBy(result => result.CategoryName)
|
.ThenBy(result => result.CategoryName)
|
||||||
.Take(8)
|
.Take(8)
|
||||||
@@ -65,7 +71,9 @@ public static partial class PublicEndpoints
|
|||||||
|
|
||||||
var archiveYearRows = await db.Results
|
var archiveYearRows = await db.Results
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(result => result.Season.Year < season.Year)
|
.Where(result =>
|
||||||
|
result.Season.Year < season.Year
|
||||||
|
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||||
.GroupBy(result => result.Season.Year)
|
.GroupBy(result => result.Season.Year)
|
||||||
.Select(group => new
|
.Select(group => new
|
||||||
{
|
{
|
||||||
@@ -83,6 +91,33 @@ public static partial class PublicEndpoints
|
|||||||
var publicCategories = season.Categories
|
var publicCategories = season.Categories
|
||||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
var featuredCategories = publicCategories
|
||||||
|
.GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var ordered = group
|
||||||
|
.OrderBy(category => category.SortOrder)
|
||||||
|
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var first = ordered[0];
|
||||||
|
var maxNomineesPerUser = ordered
|
||||||
|
.Select(category => category.MaxNomineesPerUser)
|
||||||
|
.Where(value => value > 0)
|
||||||
|
.DefaultIfEmpty(3)
|
||||||
|
.Max();
|
||||||
|
|
||||||
|
return new FeaturedCategoryDto(
|
||||||
|
first.Id,
|
||||||
|
first.GroupName,
|
||||||
|
first.GroupName,
|
||||||
|
first.Description,
|
||||||
|
maxNomineesPerUser);
|
||||||
|
})
|
||||||
|
.OrderBy(category => publicCategories
|
||||||
|
.Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Min(item => item.SortOrder))
|
||||||
|
.ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
var response = new OverviewResponse(
|
var response = new OverviewResponse(
|
||||||
season.Id,
|
season.Id,
|
||||||
season.Year,
|
season.Year,
|
||||||
@@ -100,20 +135,15 @@ public static partial class PublicEndpoints
|
|||||||
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
||||||
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||||
},
|
},
|
||||||
publicCategories
|
featuredCategories,
|
||||||
.Select(category => new FeaturedCategoryDto(
|
|
||||||
category.Id,
|
|
||||||
category.GroupName,
|
|
||||||
category.Name,
|
|
||||||
category.Description,
|
|
||||||
category.MaxNomineesPerUser))
|
|
||||||
.ToArray(),
|
|
||||||
winnerPreviewItems,
|
winnerPreviewItems,
|
||||||
archiveYears,
|
archiveYears,
|
||||||
new PublicSiteContentDto(
|
new PublicSiteContentDto(
|
||||||
siteSettings.HostDisplayName,
|
siteSettings.HostDisplayName,
|
||||||
siteSettings.HostTagline,
|
siteSettings.HostTagline,
|
||||||
siteSettings.NewsletterUrl,
|
siteSettings.NewsletterUrl,
|
||||||
|
siteSettings.ShareXUrl,
|
||||||
|
siteSettings.ShareDiscordUrl,
|
||||||
siteSettings.PrivacyEmail,
|
siteSettings.PrivacyEmail,
|
||||||
siteSettings.PrivacyPolicyContent,
|
siteSettings.PrivacyPolicyContent,
|
||||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||||
@@ -124,11 +154,14 @@ public static partial class PublicEndpoints
|
|||||||
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||||
: siteSettings.ClipSubmissionDisabledMessage,
|
: siteSettings.ClipSubmissionDisabledMessage,
|
||||||
siteSettings.ShowactApplicationsEnabled,
|
showactApplicationsOpenNow,
|
||||||
|
siteSettings.ShowactApplicationStartsAt,
|
||||||
|
siteSettings.ShowactApplicationEndsAt,
|
||||||
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
||||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||||
: siteSettings.ShowactApplicationDisabledMessage,
|
: siteSettings.ShowactApplicationDisabledMessage,
|
||||||
siteSettings.SponsorsVisible),
|
siteSettings.SponsorsVisible,
|
||||||
|
siteSettings.ShowactFormSchemaJson ?? "[]"),
|
||||||
SeasonMappings.ReadFaqItems(siteSettings));
|
SeasonMappings.ReadFaqItems(siteSettings));
|
||||||
|
|
||||||
return Results.Ok(response);
|
return Results.Ok(response);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
using Backend.Common;
|
using Backend.Common;
|
||||||
|
using Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
|
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
|
||||||
var publicCategories = season.Categories
|
var publicCategories = season.Categories
|
||||||
|
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
|
||||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||||
@@ -54,6 +57,8 @@ public static partial class PublicEndpoints
|
|||||||
category.GroupName,
|
category.GroupName,
|
||||||
category.Description,
|
category.Description,
|
||||||
category.MaxNomineesPerUser,
|
category.MaxNomineesPerUser,
|
||||||
|
category.ViewerRangeMin,
|
||||||
|
category.ViewerRangeMax,
|
||||||
category.Candidates
|
category.Candidates
|
||||||
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||||
.Select(candidate =>
|
.Select(candidate =>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
|
|||||||
|
|
||||||
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||||
if (!usesDatabaseDemo)
|
if (!usesDatabaseDemo)
|
||||||
{
|
{
|
||||||
return IsDemoLoginEnabled(configuration);
|
return IsDemoLoginEnabled(configuration);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
|
|||||||
.Select(item => new
|
.Select(item => new
|
||||||
{
|
{
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
|
item.CategoryGroupName,
|
||||||
item.Status,
|
item.Status,
|
||||||
Nominee = item.CandidateId != null
|
Nominee = item.CandidateId != null
|
||||||
? item.Candidate!.DisplayName
|
? item.Candidate!.DisplayName
|
||||||
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
|
|||||||
var groupedNominations = nominations
|
var groupedNominations = nominations
|
||||||
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||||
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||||
.GroupBy(item => item.CategoryId)
|
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
|
||||||
.Select(group => new UserNominationStateDto(
|
.Select(group => new UserNominationStateDto(
|
||||||
group.Key,
|
group.Key.CategoryId,
|
||||||
|
group.Key.CategoryGroupName,
|
||||||
group.Select(item => item.Nominee!)
|
group.Select(item => item.Nominee!)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray()))
|
.ToArray()))
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ public static class ServiceCollectionExtensions
|
|||||||
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
|
services.AddHttpClient("TwitchTracker", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(4);
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
|
||||||
|
});
|
||||||
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
||||||
|
|
||||||
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
||||||
@@ -88,6 +93,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
||||||
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
||||||
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
||||||
|
services.AddScoped<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
|
||||||
|
services.AddScoped<NominationTrackingReviewService>();
|
||||||
|
services.AddScoped<NominationEnrichmentService>();
|
||||||
services.AddScoped<AdminSessionFilter>();
|
services.AddScoped<AdminSessionFilter>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
+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> CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default);
|
||||||
Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default);
|
Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default);
|
||||||
Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
|
Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||||
|
Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default);
|
||||||
Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default);
|
Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Common;
|
||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
using Backend.Domain;
|
using Backend.Domain;
|
||||||
using Backend.Repositories;
|
using Backend.Repositories;
|
||||||
using Backend.Security;
|
using Backend.Security;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace Backend.Services;
|
namespace Backend.Services;
|
||||||
|
|
||||||
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
|
public sealed class UserSessionService(IUserSessionRepository userSessionRepository, AwardsDbContext db) : IUserSessionService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12);
|
public const int MinimumIdleTimeoutHours = 3;
|
||||||
|
public const int DefaultIdleTimeoutHours = 3;
|
||||||
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
|
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
|
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
|
||||||
@@ -27,7 +30,8 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
if (IsExpired(session, now))
|
var idleSessionLifetime = await ResolveIdleSessionLifetimeAsync(cancellationToken);
|
||||||
|
if (IsExpired(session, now, idleSessionLifetime))
|
||||||
{
|
{
|
||||||
session.IsActive = false;
|
session.IsActive = false;
|
||||||
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
||||||
@@ -40,6 +44,17 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var configuredHours = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Id == 1)
|
||||||
|
.Select(item => (int?)item.SessionIdleTimeoutHours)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return NormalizeIdleTimeoutHours(configuredHours);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
|
public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return CreateSessionAsync(
|
return CreateSessionAsync(
|
||||||
@@ -94,9 +109,18 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsExpired(UserSession session, DateTimeOffset now) =>
|
private async Task<TimeSpan> ResolveIdleSessionLifetimeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var idleTimeoutHours = await GetIdleTimeoutHoursAsync(cancellationToken);
|
||||||
|
return TimeSpan.FromHours(idleTimeoutHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int NormalizeIdleTimeoutHours(int? configuredHours) =>
|
||||||
|
Math.Max(MinimumIdleTimeoutHours, configuredHours ?? DefaultIdleTimeoutHours);
|
||||||
|
|
||||||
|
private static bool IsExpired(UserSession session, DateTimeOffset now, TimeSpan idleSessionLifetime) =>
|
||||||
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|
||||||
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime);
|
|| session.LastSeenAt <= now.Subtract(idleSessionLifetime);
|
||||||
|
|
||||||
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
|
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class WorkflowRuleSettings
|
|||||||
public const string MaxCandidateAppearances = "max_candidate_appearances";
|
public const string MaxCandidateAppearances = "max_candidate_appearances";
|
||||||
public const string MaxWinnerPlacements = "max_winner_placements";
|
public const string MaxWinnerPlacements = "max_winner_placements";
|
||||||
public const string WinnerRequiresClip = "winner_requires_clip";
|
public const string WinnerRequiresClip = "winner_requires_clip";
|
||||||
|
public const string RecommendedNominatorsPerSubcategory = "recommended_nominators_per_subcategory";
|
||||||
|
|
||||||
public static WorkflowRuleSetting[] Defaults { get; } =
|
public static WorkflowRuleSetting[] Defaults { get; } =
|
||||||
[
|
[
|
||||||
@@ -26,11 +27,28 @@ public static class WorkflowRuleSettings
|
|||||||
new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."),
|
new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."),
|
||||||
new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."),
|
new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."),
|
||||||
new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."),
|
new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."),
|
||||||
|
new(RecommendedNominatorsPerSubcategory, "Empfohlene Nominierer pro Unterkategorie", true, 4, "warn", "Zeigt in der Kategorie-Übersicht an, ab wann eine Unterkategorie nominierungsseitig gut getragen ist. Diese Regel blockiert nichts."),
|
||||||
];
|
];
|
||||||
|
|
||||||
public static WorkflowRuleSetting[] Read(SiteSettings? settings)
|
public static WorkflowRuleSetting[] Read(SiteSettings? settings)
|
||||||
{
|
{
|
||||||
var storedRules = Parse(settings?.WorkflowRulesJson);
|
var storedRules = Parse(settings?.WorkflowRulesJson);
|
||||||
|
return Read(storedRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WorkflowRuleSetting[] Read(Season? season, SiteSettings? settings)
|
||||||
|
{
|
||||||
|
var seasonRules = Parse(season?.WorkflowRulesJson);
|
||||||
|
if (seasonRules.Length > 0)
|
||||||
|
{
|
||||||
|
return Read(seasonRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Read(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowRuleSetting[] Read(WorkflowRuleSetting[] storedRules)
|
||||||
|
{
|
||||||
return Defaults
|
return Defaults
|
||||||
.Select(defaultRule =>
|
.Select(defaultRule =>
|
||||||
{
|
{
|
||||||
@@ -98,6 +116,11 @@ public static class WorkflowRuleSettings
|
|||||||
_ => fallback.Mode,
|
_ => fallback.Mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (string.Equals(fallback.Key, RecommendedNominatorsPerSubcategory, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
mode = "warn";
|
||||||
|
}
|
||||||
|
|
||||||
return rule with
|
return rule with
|
||||||
{
|
{
|
||||||
Key = fallback.Key,
|
Key = fallback.Key,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { computed, reactive, ref } from 'vue'
|
|||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShellAccountModals from './AppShellAccountModals.vue'
|
import AppShellAccountModals from './AppShellAccountModals.vue'
|
||||||
|
import AdminToastViewport from './admin/AdminToastViewport.vue'
|
||||||
|
import { useAdminApiDisconnectToast } from '../composables/useAdminApiDisconnectToast'
|
||||||
import { useBodyScrollLock } from '../composables/useBodyScrollLock'
|
import { useBodyScrollLock } from '../composables/useBodyScrollLock'
|
||||||
import { privacyContentToHtml } from '../lib/privacyContent'
|
import { privacyContentToHtml } from '../lib/privacyContent'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
@@ -35,6 +37,7 @@ const navItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const visibleNavItems = computed(() => navItems)
|
const visibleNavItems = computed(() => navItems)
|
||||||
|
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||||
const privacyContent = computed(
|
const privacyContent = computed(
|
||||||
() => awardsStore.overview.siteContent.privacyPolicyContent || awardsStore.adminSiteSettings.privacyPolicyContent,
|
() => awardsStore.overview.siteContent.privacyPolicyContent || awardsStore.adminSiteSettings.privacyPolicyContent,
|
||||||
)
|
)
|
||||||
@@ -140,6 +143,7 @@ const linkBase = 'padding:9px 14px;border-radius:9px;font-family:\'Outfit\',sans
|
|||||||
const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
|
const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
|
||||||
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
|
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
|
||||||
|
|
||||||
|
useAdminApiDisconnectToast(isAdminRoute)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -231,6 +235,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<AdminToastViewport />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,41 @@
|
|||||||
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
|
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<section v-if="identitySummary || ruleNotices.length" class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Regel-Kontext</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Diese Werte helfen bei mehrfachen Nominierungen und Gewinnergrenzen.</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Kandidaturen</p>
|
||||||
|
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.appearances }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Angenommen</p>
|
||||||
|
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.acceptedAppearances }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Gewinner</p>
|
||||||
|
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.winnerPlacements }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Viewer-Nominierungen</p>
|
||||||
|
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.nominationTally }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="ruleNotices.length" class="mt-3 space-y-2">
|
||||||
|
<p
|
||||||
|
v-for="notice in ruleNotices"
|
||||||
|
:key="notice.message"
|
||||||
|
class="rounded-2xl border px-4 py-3 text-sm font-semibold"
|
||||||
|
:class="notice.mode === 'block' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-amber-100 bg-amber-50 text-amber-700'"
|
||||||
|
>
|
||||||
|
{{ notice.mode === 'block' ? 'Blockiert' : 'Warnung' }}: {{ notice.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
|
<section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p>
|
||||||
@@ -128,6 +163,13 @@ defineProps<{
|
|||||||
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
||||||
clipEmbedStatusOptions: Array<{ label: string; value: string }>
|
clipEmbedStatusOptions: Array<{ label: string; value: string }>
|
||||||
selectedPlatformValue: string
|
selectedPlatformValue: string
|
||||||
|
identitySummary: {
|
||||||
|
appearances: number
|
||||||
|
acceptedAppearances: number
|
||||||
|
winnerPlacements: number
|
||||||
|
nominationTally: number
|
||||||
|
} | null
|
||||||
|
ruleNotices: Array<{ mode: 'warn' | 'block'; message: string }>
|
||||||
canSave: boolean
|
canSave: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||||
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
|
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
{{ candidate.nominationTally }} Viewer-Nominierungen
|
||||||
|
<template v-if="candidateIdentitySummaries[identityKey(candidate)]">
|
||||||
|
· {{ candidateIdentitySummaries[identityKey(candidate)].appearances }} Kandidaturen
|
||||||
|
· {{ candidateIdentitySummaries[identityKey(candidate)].winnerPlacements }} Gewinnerplätze
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
||||||
Mögliches Duplikat in dieser Kategorie
|
Mögliches Duplikat in dieser Kategorie
|
||||||
</p>
|
</p>
|
||||||
@@ -122,6 +129,7 @@ const props = defineProps<{
|
|||||||
rangeEnd: number
|
rangeEnd: number
|
||||||
categoryLabelMap: Record<number, string>
|
categoryLabelMap: Record<number, string>
|
||||||
duplicateCandidateKeys: Map<string, number>
|
duplicateCandidateKeys: Map<string, number>
|
||||||
|
candidateIdentitySummaries: Record<string, { appearances: number; acceptedAppearances: number; winnerPlacements: number; nominationTally: number }>
|
||||||
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
|
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
|
||||||
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
||||||
}>()
|
}>()
|
||||||
@@ -138,6 +146,15 @@ function isDuplicate(candidate: AdminCandidateItem) {
|
|||||||
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
|
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function identityKey(candidate: AdminCandidateItem) {
|
||||||
|
if (typeof candidate.streamerIdentityId === 'number') {
|
||||||
|
return `identity:${candidate.streamerIdentityId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
|
||||||
|
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
function acceptanceLabel(value: string) {
|
function acceptanceLabel(value: string) {
|
||||||
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
|
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:open="open"
|
||||||
|
size="lg"
|
||||||
|
:title="title"
|
||||||
|
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewaehlten Season."
|
||||||
|
@close="$emit('close')"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="space-y-2 md:col-span-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||||
|
<input
|
||||||
|
:value="form.groupName"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="z. B. Gaming"
|
||||||
|
@input="form.groupName = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||||
|
<input
|
||||||
|
:value="form.sortOrder"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="200"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
@input="form.sortOrder = toPositiveInteger(($event.target as HTMLInputElement).value, 1)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierungs-Limit</span>
|
||||||
|
<input
|
||||||
|
:value="form.maxNomineesPerUser"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
@input="form.maxNomineesPerUser = toPositiveInteger(($event.target as HTMLInputElement).value, 3)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2 md:col-span-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||||
|
<textarea
|
||||||
|
:value="form.description"
|
||||||
|
class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Kurze Orientierung fuer Admins und Public-Flows"
|
||||||
|
@input="form.description = ($event.target as HTMLTextAreaElement).value"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!canCreateGroup && !isEditing" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||||
|
Lege zuerst globale Unterkategorien an, bevor du neue Hauptkategorien erzeugst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
v-if="isEditing"
|
||||||
|
variant="ghost"
|
||||||
|
class="mr-auto gap-2 border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100"
|
||||||
|
@click="$emit('delete')"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Loeschen
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||||
|
<Button :disabled="!canSave || saving" @click="$emit('save')">
|
||||||
|
{{ saving ? 'Speichert ...' : isEditing ? 'Hauptkategorie speichern' : 'Hauptkategorie anlegen' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Trash2 } from '@lucide/vue'
|
||||||
|
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import Modal from '../ui/Modal.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
form: {
|
||||||
|
groupName: string
|
||||||
|
description: string
|
||||||
|
sortOrder: number
|
||||||
|
maxNomineesPerUser: number
|
||||||
|
}
|
||||||
|
canSave: boolean
|
||||||
|
canCreateGroup: boolean
|
||||||
|
saving: boolean
|
||||||
|
isEditing: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toPositiveInteger(value: string, fallback: number) {
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? Math.max(1, Math.round(number)) : fallback
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<template>
|
||||||
|
<Modal :open="open" size="xl" :title="title" :subtitle="subtitle" @close="$emit('close')">
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-500">Unterkategorien</p>
|
||||||
|
<strong class="mt-2 block text-2xl text-violet-900">{{ categories.length }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-emerald-100 bg-emerald-50/70 px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-600">Kandidaten</p>
|
||||||
|
<strong class="mt-2 block text-2xl text-emerald-800">{{ candidates.length }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600">Nominierungen</p>
|
||||||
|
<strong class="mt-2 block text-2xl text-amber-800">{{ nominations.length }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-sky-100 bg-sky-50/70 px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-sky-600">Nominierer</p>
|
||||||
|
<strong class="mt-2 block text-2xl text-sky-800">{{ totalNominatorCount }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="readinessItems.length > 0" class="mt-4 grid gap-2">
|
||||||
|
<article
|
||||||
|
v-for="item in readinessItems"
|
||||||
|
:key="item.category.id"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3"
|
||||||
|
:class="item.isReady ? 'border-emerald-100 bg-emerald-50/70' : 'border-sky-100 bg-sky-50/70'"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-semibold" :class="item.isReady ? 'text-emerald-900' : 'text-sky-900'">
|
||||||
|
{{ item.category.groupName }} · {{ item.category.name }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs" :class="item.isReady ? 'text-emerald-700' : 'text-sky-700'">
|
||||||
|
{{ item.nominatorCount }} von {{ readinessTargetLabel }} Nominierern erreicht. Hinweis, kein Blocker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.isReady ? 'bg-emerald-100 text-emerald-700' : 'bg-white/80 text-sky-700'">
|
||||||
|
{{ item.isReady ? 'Bereit' : 'Aufbau' }}
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-5 lg:grid-cols-[0.82fr_1.18fr]">
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Tree-Ausschnitt</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Aktuelle Struktur und Belegung.</p>
|
||||||
|
</div>
|
||||||
|
<article
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.id"
|
||||||
|
class="rounded-2xl border border-violet-100 bg-white px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||||
|
<p class="mt-1 font-mono text-xs text-slate-400">/{{ category.slug }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
|
||||||
|
{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">
|
||||||
|
{{ category.candidateCount }} Kandidaten
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">
|
||||||
|
{{ category.pendingCount }} Reviews
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
|
||||||
|
{{ category.nominatorCount }}/{{ readinessTargetLabel }} Nominierer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Nominierungs-Leaderboard</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Standard: viele Nominierungen vor wenigen Nominierungen.</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||||
|
{{ filteredLeaderboardRows.length }} Einträge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Leaderboard filtern</span>
|
||||||
|
<input
|
||||||
|
v-model="leaderboardQuery"
|
||||||
|
class="h-10 w-full rounded-2xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Streamer, Link oder Plattform filtern"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="leaderboardStatusFilter"
|
||||||
|
class="h-10 rounded-2xl border border-violet-200 bg-white px-3 text-sm font-semibold text-slate-600 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Status</option>
|
||||||
|
<option value="pending">Offen</option>
|
||||||
|
<option value="approved">Übernommen</option>
|
||||||
|
<option value="rejected">Verworfen</option>
|
||||||
|
<option value="candidate">Kandidaten</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="leaderboardSort"
|
||||||
|
class="h-10 rounded-2xl border border-violet-200 bg-white px-3 text-sm font-semibold text-slate-600 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
>
|
||||||
|
<option value="votes-desc">Viele Votes</option>
|
||||||
|
<option value="votes-asc">Wenige Votes</option>
|
||||||
|
<option value="name-asc">Name A-Z</option>
|
||||||
|
<option value="recent-desc">Neueste</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredLeaderboardRows.length > 0" class="grid gap-2">
|
||||||
|
<article
|
||||||
|
v-for="(row, index) in filteredLeaderboardRows"
|
||||||
|
:key="row.key"
|
||||||
|
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-sm font-black text-violet-700">
|
||||||
|
#{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ row.label }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-slate-500">
|
||||||
|
{{ row.platform || 'Plattform offen' }} · {{ row.url || 'kein Link' }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="status in row.statuses"
|
||||||
|
:key="`${row.key}-${status}`"
|
||||||
|
class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]"
|
||||||
|
:class="statusBadgeClass(status)"
|
||||||
|
>
|
||||||
|
{{ statusLabel(status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sm:text-right">
|
||||||
|
<strong class="block text-2xl text-violet-900">{{ row.voteCount }}</strong>
|
||||||
|
<span class="text-[11px] font-semibold uppercase tracking-[0.14em] text-violet-500">Nominierer</span>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ row.nominationCount }} Einreichungen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
|
||||||
|
Keine Leaderboard-Einträge passen zum Filter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Kandidaten</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Finale und vorbereitete Kandidaten in diesem Ausschnitt.</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="candidates.length > 0" class="grid gap-2">
|
||||||
|
<article
|
||||||
|
v-for="candidate in candidates"
|
||||||
|
:key="candidate.id"
|
||||||
|
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ candidate.displayName }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-slate-500">{{ candidate.platform }} · {{ candidate.channelSlug }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
|
||||||
|
{{ candidate.nominationTally }}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
|
||||||
|
Noch keine Kandidaten in diesem Ausschnitt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Roh-Nominierungen</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Offene und bereits gepruefte Einreichungen zur Kontrolle.</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="nominations.length > 0" class="grid gap-2">
|
||||||
|
<article
|
||||||
|
v-for="nomination in nominations"
|
||||||
|
:key="nomination.id"
|
||||||
|
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ nomination.resolvedChannel || nomination.candidateText || 'Ohne Namen' }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-slate-500">{{ nomination.streamUrl || nomination.categoryGroupName }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="nomination.status === 'pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-600'">
|
||||||
|
{{ nomination.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
|
||||||
|
Keine Nominierungen in diesem Ausschnitt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button variant="ghost" @click="$emit('close')">Schliessen</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import Modal from '../ui/Modal.vue'
|
||||||
|
import type { AdminCategoryGroupSummary } from './useAdminCategoryManager'
|
||||||
|
import { formatViewerRange } from './useAdminCategoryManager'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
categories: AdminCategoryGroupSummary['categories']
|
||||||
|
candidates: AdminCandidateItem[]
|
||||||
|
nominations: AdminNominationReviewItem[]
|
||||||
|
recommendedNominatorTarget: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type LeaderboardStatus = 'pending' | 'approved' | 'rejected' | 'candidate' | string
|
||||||
|
type LeaderboardStatusFilter = 'all' | 'pending' | 'approved' | 'rejected' | 'candidate'
|
||||||
|
type LeaderboardSort = 'votes-desc' | 'votes-asc' | 'name-asc' | 'recent-desc'
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
platform: string
|
||||||
|
url: string
|
||||||
|
voteCount: number
|
||||||
|
nominationCount: number
|
||||||
|
latestCreatedAt: string
|
||||||
|
statuses: LeaderboardStatus[]
|
||||||
|
nominatorKeys: string[]
|
||||||
|
searchText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboardQuery = ref('')
|
||||||
|
const leaderboardStatusFilter = ref<LeaderboardStatusFilter>('all')
|
||||||
|
const leaderboardSort = ref<LeaderboardSort>('votes-desc')
|
||||||
|
|
||||||
|
const totalNominatorCount = computed(() => uniqueNominatorCount(props.nominations))
|
||||||
|
const readinessTargetLabel = computed(() => props.recommendedNominatorTarget === null ? '' : String(props.recommendedNominatorTarget))
|
||||||
|
|
||||||
|
const readinessItems = computed(() =>
|
||||||
|
props.recommendedNominatorTarget === null
|
||||||
|
? []
|
||||||
|
: props.categories.map((category) => {
|
||||||
|
const target = props.recommendedNominatorTarget ?? 0
|
||||||
|
const nominations = props.nominations.filter((nomination) =>
|
||||||
|
(nomination.categoryId !== null && nomination.categoryId === category.id)
|
||||||
|
|| (nomination.suggestedCategoryId !== null && nomination.suggestedCategoryId === category.id),
|
||||||
|
)
|
||||||
|
const nominatorCount = uniqueNominatorCount(nominations)
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
nominatorCount,
|
||||||
|
isReady: nominatorCount >= target,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const leaderboardRows = computed<LeaderboardRow[]>(() => {
|
||||||
|
const rows = new Map<string, LeaderboardRow>()
|
||||||
|
|
||||||
|
for (const nomination of props.nominations) {
|
||||||
|
const key = nominationKey(nomination)
|
||||||
|
const existing = rows.get(key)
|
||||||
|
const label = nomination.resolvedChannel || nomination.candidateDisplayName || nomination.candidateText || nomination.streamUrl || 'Ohne Namen'
|
||||||
|
const platform = nomination.resolvedPlatform || ''
|
||||||
|
const url = nomination.streamUrl || ''
|
||||||
|
const status = nomination.status || 'pending'
|
||||||
|
const createdAt = nomination.createdAt || ''
|
||||||
|
const row = existing ?? {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
platform,
|
||||||
|
url,
|
||||||
|
voteCount: 0,
|
||||||
|
nominationCount: 0,
|
||||||
|
latestCreatedAt: createdAt,
|
||||||
|
statuses: [],
|
||||||
|
nominatorKeys: [],
|
||||||
|
searchText: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
row.nominationCount += 1
|
||||||
|
const nominatorKey = nomination.submittedByTwitchId.trim().toLowerCase()
|
||||||
|
if (nominatorKey && !row.nominatorKeys.includes(nominatorKey)) {
|
||||||
|
row.nominatorKeys.push(nominatorKey)
|
||||||
|
}
|
||||||
|
row.voteCount = Math.max(row.voteCount, row.nominatorKeys.length)
|
||||||
|
if (!row.statuses.includes(status)) row.statuses.push(status)
|
||||||
|
if (createdAt && (!row.latestCreatedAt || createdAt > row.latestCreatedAt)) row.latestCreatedAt = createdAt
|
||||||
|
row.searchText = [row.label, row.platform, row.url, row.statuses.join(' ')].join(' ').toLowerCase()
|
||||||
|
rows.set(key, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of props.candidates) {
|
||||||
|
const key = candidateKey(candidate)
|
||||||
|
const existing = rows.get(key)
|
||||||
|
const row = existing ?? {
|
||||||
|
key,
|
||||||
|
label: candidate.displayName,
|
||||||
|
platform: candidate.platform,
|
||||||
|
url: candidate.channelSlug,
|
||||||
|
voteCount: 0,
|
||||||
|
nominationCount: 0,
|
||||||
|
latestCreatedAt: '',
|
||||||
|
statuses: [],
|
||||||
|
nominatorKeys: [],
|
||||||
|
searchText: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
row.label = row.label || candidate.displayName
|
||||||
|
row.platform = row.platform || candidate.platform
|
||||||
|
row.url = row.url || candidate.channelSlug
|
||||||
|
row.voteCount = Math.max(row.voteCount, candidate.nominationTally)
|
||||||
|
if (!row.statuses.includes('candidate')) row.statuses.push('candidate')
|
||||||
|
row.searchText = [row.label, row.platform, row.url, row.statuses.join(' ')].join(' ').toLowerCase()
|
||||||
|
rows.set(key, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...rows.values()]
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredLeaderboardRows = computed(() => {
|
||||||
|
const search = leaderboardQuery.value.trim().toLowerCase()
|
||||||
|
const statusFilter = leaderboardStatusFilter.value
|
||||||
|
const filtered = leaderboardRows.value.filter((row) =>
|
||||||
|
(!search || row.searchText.includes(search))
|
||||||
|
&& (statusFilter === 'all' || row.statuses.includes(statusFilter)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return filtered.sort((left, right) => {
|
||||||
|
if (leaderboardSort.value === 'votes-asc') return left.voteCount - right.voteCount || left.nominationCount - right.nominationCount || left.label.localeCompare(right.label, 'de')
|
||||||
|
if (leaderboardSort.value === 'name-asc') return left.label.localeCompare(right.label, 'de')
|
||||||
|
if (leaderboardSort.value === 'recent-desc') return right.latestCreatedAt.localeCompare(left.latestCreatedAt) || right.voteCount - left.voteCount || right.nominationCount - left.nominationCount
|
||||||
|
return right.voteCount - left.voteCount || right.nominationCount - left.nominationCount || left.label.localeCompare(right.label, 'de')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function nominationKey(nomination: AdminNominationReviewItem) {
|
||||||
|
if (nomination.streamerIdentityId !== null) return `identity:${nomination.streamerIdentityId}`
|
||||||
|
const channel = (nomination.resolvedChannel || nomination.candidateText || '').trim().toLowerCase()
|
||||||
|
if (channel) return `channel:${channel}`
|
||||||
|
const url = (nomination.streamUrl || '').trim().toLowerCase()
|
||||||
|
if (url) return `url:${url}`
|
||||||
|
return `nomination:${nomination.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateKey(candidate: AdminCandidateItem) {
|
||||||
|
if (candidate.streamerIdentityId !== null) return `identity:${candidate.streamerIdentityId}`
|
||||||
|
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
|
||||||
|
if (channel) return `channel:${channel}`
|
||||||
|
return `candidate:${candidate.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNominatorCount(nominations: AdminNominationReviewItem[]) {
|
||||||
|
return new Set(nominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean)).size
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: LeaderboardStatus) {
|
||||||
|
if (status === 'pending') return 'offen'
|
||||||
|
if (status === 'approved') return 'übernommen'
|
||||||
|
if (status === 'rejected') return 'verworfen'
|
||||||
|
if (status === 'candidate') return 'Kandidat'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: LeaderboardStatus) {
|
||||||
|
if (status === 'pending') return 'bg-amber-50 text-amber-700'
|
||||||
|
if (status === 'approved' || status === 'candidate') return 'bg-emerald-50 text-emerald-700'
|
||||||
|
if (status === 'rejected') return 'bg-rose-50 text-rose-700'
|
||||||
|
return 'bg-slate-100 text-slate-600'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<Modal :open="open" size="lg" :title="title" subtitle="Diese Unterkategorie wird spaeter bei allen Hauptkategorien verwendet." @close="$emit('close')">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="space-y-2 md:col-span-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||||
|
<input
|
||||||
|
:value="form.name"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="z. B. Rising Star"
|
||||||
|
@input="form.name = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2 md:col-span-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
:value="form.slug"
|
||||||
|
type="text"
|
||||||
|
class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="rising-star"
|
||||||
|
@input="form.slug = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" class="border border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100" @click="$emit('fill-slug')">
|
||||||
|
Auto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer von</span>
|
||||||
|
<input
|
||||||
|
:value="form.viewerRangeMin ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="0"
|
||||||
|
@input="form.viewerRangeMin = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer bis</span>
|
||||||
|
<input
|
||||||
|
:value="form.viewerRangeMax ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="offen"
|
||||||
|
@input="form.viewerRangeMax = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||||
|
<input
|
||||||
|
:value="form.sortOrder"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
@input="form.sortOrder = Math.max(1, Number(($event.target as HTMLInputElement).value) || 1)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||||
|
<Button :disabled="!canSave" @click="$emit('save')">Unterkategorie speichern</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import Modal from '../ui/Modal.vue'
|
||||||
|
import type { AdminSubcategoryDraft } from './useAdminCategoryManager'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
form: AdminSubcategoryDraft
|
||||||
|
canSave: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: []
|
||||||
|
'fill-slug': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toNullableNumber(value: string) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const number = Number(trimmed)
|
||||||
|
return Number.isFinite(number) ? number : null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:open="open"
|
||||||
|
size="xl"
|
||||||
|
title="Unterkategorien konfigurieren"
|
||||||
|
subtitle="Diese Viewer-Sektionen gelten fuer alle Hauptkategorien der ausgewaehlten Season."
|
||||||
|
@close="$emit('close')"
|
||||||
|
>
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-slate-900">Globale Unterkategorien</p>
|
||||||
|
<p class="text-sm text-slate-500">Die Hauptkategorien muessen sie nicht einzeln anlegen.</p>
|
||||||
|
</div>
|
||||||
|
<Button class="gap-2" @click="$emit('add-subcategory')">
|
||||||
|
<PlusCircle class="h-4 w-4" />
|
||||||
|
Unterkategorie
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="subcategories.length > 0" class="grid gap-3">
|
||||||
|
<article
|
||||||
|
v-for="(subcategory, index) in subcategories"
|
||||||
|
:key="`${subcategory.slug}-${index}`"
|
||||||
|
class="rounded-2xl border border-violet-100 bg-white px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="text-base font-bold text-slate-900">{{ subcategory.name || 'Neue Unterkategorie' }}</h3>
|
||||||
|
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
|
||||||
|
{{ formatViewerRange(subcategory.viewerRangeMin, subcategory.viewerRangeMax) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-mono text-xs text-slate-400">/{{ subcategory.slug || 'slug-fehlt' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-600">
|
||||||
|
#{{ subcategory.sortOrder }}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" class="gap-2" @click="$emit('edit-subcategory', index)">
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" class="gap-2 border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100" @click="$emit('remove-subcategory', index)">
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-8 text-center text-sm text-slate-500">
|
||||||
|
Noch keine Unterkategorien vorhanden.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
||||||
|
<div v-if="editorOpen" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ editorTitle }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Aenderungen werden erst mit der gesamten Unterkategorien-Konfiguration gespeichert.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||||
|
<input
|
||||||
|
:value="form.name"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="z. B. Rising Star"
|
||||||
|
@input="form.name = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
:value="form.slug"
|
||||||
|
type="text"
|
||||||
|
class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="rising-star"
|
||||||
|
@input="form.slug = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" class="border border-violet-200 bg-white text-violet-700 hover:bg-violet-50" @click="$emit('fill-slug')">
|
||||||
|
Auto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer von</span>
|
||||||
|
<input
|
||||||
|
:value="form.viewerRangeMin ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="0"
|
||||||
|
@input="form.viewerRangeMin = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer bis</span>
|
||||||
|
<input
|
||||||
|
:value="form.viewerRangeMax ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="offen"
|
||||||
|
@input="form.viewerRangeMax = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||||
|
<input
|
||||||
|
:value="form.sortOrder"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
@input="form.sortOrder = Math.max(1, Number(($event.target as HTMLInputElement).value) || 1)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" @click="$emit('close-editor')">Abbrechen</Button>
|
||||||
|
<Button :disabled="!canSaveSubcategory" @click="$emit('save-subcategory')">
|
||||||
|
Unterkategorie uebernehmen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed border-violet-200 bg-white/60 px-4 py-8 text-center">
|
||||||
|
<Layers3 class="h-8 w-8 text-violet-300" />
|
||||||
|
<p class="mt-3 font-semibold text-slate-700">Unterkategorie auswaehlen</p>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">Bearbeite eine bestehende Sektion oder lege eine neue Viewer-Sektion an.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||||
|
<Button :disabled="!canSave || saving" @click="$emit('save')">
|
||||||
|
{{ saving ? 'Speichert ...' : 'Unterkategorien speichern' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Layers3, Pencil, PlusCircle, Trash2 } from '@lucide/vue'
|
||||||
|
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import Modal from '../ui/Modal.vue'
|
||||||
|
import type { AdminSubcategoryDraft } from './useAdminCategoryManager'
|
||||||
|
import { formatViewerRange } from './useAdminCategoryManager'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
subcategories: AdminSubcategoryDraft[]
|
||||||
|
canSave: boolean
|
||||||
|
saving: boolean
|
||||||
|
editorOpen: boolean
|
||||||
|
editorTitle: string
|
||||||
|
form: AdminSubcategoryDraft
|
||||||
|
canSaveSubcategory: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: []
|
||||||
|
'add-subcategory': []
|
||||||
|
'edit-subcategory': [index: number]
|
||||||
|
'remove-subcategory': [index: number]
|
||||||
|
'close-editor': []
|
||||||
|
'save-subcategory': []
|
||||||
|
'fill-slug': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toNullableNumber(value: string) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const number = Number(trimmed)
|
||||||
|
return Number.isFinite(number) ? number : null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.name"
|
||||||
|
class="overflow-hidden rounded-2xl border bg-white/90 shadow-[0_14px_36px_rgba(124,91,180,0.08)]"
|
||||||
|
:class="selectedGroupName === group.name ? 'border-violet-200 ring-2 ring-violet-100' : 'border-violet-100'"
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 border-b border-violet-100 bg-violet-50/45 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||||
|
<button type="button" class="min-w-0 text-left" @click="$emit('edit-group', group)">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-white text-violet-700 shadow-sm">
|
||||||
|
<FolderTree class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<h2 class="truncate text-lg font-bold text-slate-900">{{ group.name }}</h2>
|
||||||
|
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">
|
||||||
|
#{{ group.sortOrder }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-600">
|
||||||
|
Limit {{ group.maxNomineesPerUser }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">
|
||||||
|
{{ group.description || 'Keine Beschreibung hinterlegt.' }}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||||
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||||
|
{{ group.candidateCount }} Kandidaten
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||||
|
{{ group.pendingCount }} Reviews
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
|
||||||
|
{{ group.nominatorCount }} Nominierer
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" class="gap-2" @click.stop="$emit('overview-group', group)">
|
||||||
|
<Info class="h-4 w-4" />
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" class="gap-2" @click.stop="$emit('edit-group', group)">
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-violet-100">
|
||||||
|
<div
|
||||||
|
v-for="category in group.categories"
|
||||||
|
:key="category.id"
|
||||||
|
class="grid gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"
|
||||||
|
:class="isFocused(category.slug) || isFocused(category.name) ? 'bg-sky-50/60' : 'bg-white'"
|
||||||
|
>
|
||||||
|
<button type="button" class="min-w-0 border-l-2 border-violet-100 pl-4 text-left" @click="$emit('overview-category', group, category)">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-violet-50 text-violet-600">
|
||||||
|
<GitBranch class="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||||
|
<span
|
||||||
|
v-if="isFocused(category.slug) || isFocused(category.name)"
|
||||||
|
class="rounded-full bg-sky-100 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700"
|
||||||
|
>
|
||||||
|
Fokus
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="statusClass(category)">
|
||||||
|
{{ statusLabel(category) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||||
|
<span class="font-mono text-slate-400">/{{ category.slug }}</span>
|
||||||
|
<span>{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||||
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||||
|
{{ category.candidateCount }} Kandidaten
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||||
|
{{ category.pendingCount }} Reviews
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
|
||||||
|
{{ category.nominatorCount }} Nominierer<span v-if="recommendedNominatorTarget !== null"> · Ziel {{ recommendedNominatorTarget }}</span>
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" class="gap-2" @click="$emit('overview-category', group, category)">
|
||||||
|
<Info class="h-4 w-4" />
|
||||||
|
Overview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div v-if="groups.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/50 px-4 py-12 text-center text-sm text-slate-500">
|
||||||
|
Keine Hauptkategorien passen zu diesem Filter.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FolderTree, GitBranch, Info, Pencil } from '@lucide/vue'
|
||||||
|
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import type { AdminCategoryGroupSummary } from './useAdminCategoryManager'
|
||||||
|
import { formatViewerRange } from './useAdminCategoryManager'
|
||||||
|
|
||||||
|
type TreeCategory = AdminCategoryGroupSummary['categories'][number]
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
groups: AdminCategoryGroupSummary[]
|
||||||
|
selectedGroupName: string | null
|
||||||
|
isFocused: (nameOrSlug: string) => boolean
|
||||||
|
recommendedNominatorTarget: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'edit-group': [group: AdminCategoryGroupSummary]
|
||||||
|
'overview-group': [group: AdminCategoryGroupSummary]
|
||||||
|
'overview-category': [group: AdminCategoryGroupSummary, category: TreeCategory]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function statusLabel(category: TreeCategory) {
|
||||||
|
if (category.pendingCount > 0) return 'Reviews'
|
||||||
|
if (category.nominatorCount >= 4) return 'Bereit'
|
||||||
|
if (category.nominatorCount > 0 || category.candidateCount > 0) return 'Aufbau'
|
||||||
|
if (category.candidateCount === 0) return 'Leer'
|
||||||
|
return 'Aufbau'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(category: TreeCategory) {
|
||||||
|
if (category.pendingCount > 0) return 'bg-amber-50 text-amber-700'
|
||||||
|
if (category.nominatorCount >= 4) return 'bg-emerald-50 text-emerald-700'
|
||||||
|
if (category.nominatorCount > 0 || category.candidateCount > 0) return 'bg-sky-50 text-sky-700'
|
||||||
|
if (category.candidateCount === 0) return 'bg-rose-50 text-rose-700'
|
||||||
|
return 'bg-sky-50 text-sky-700'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -29,6 +29,14 @@
|
|||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter URL</span>
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter URL</span>
|
||||||
<input v-model="form.newsletterUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
<input v-model="form.newsletterUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Auf X teilen – URL</span>
|
||||||
|
<input v-model="form.shareXUrl" type="url" placeholder="https://x.com/intent/tweet?..." class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Auf Discord teilen – URL</span>
|
||||||
|
<input v-model="form.shareDiscordUrl" type="url" placeholder="https://discord.gg/..." class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
|
import { Eye, Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
|
||||||
|
|
||||||
|
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
|
import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import Card from '../ui/Card.vue'
|
import AdminContentFooterPreviewModal from './AdminContentFooterPreviewModal.vue'
|
||||||
|
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
||||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||||
|
import type { AdminContentForm } from './adminContentTypes'
|
||||||
|
import AdminShowactFormBuilder from './AdminShowactFormBuilder.vue'
|
||||||
|
import Modal from '../ui/Modal.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
section: 'showacts' | 'sponsors'
|
||||||
|
form: AdminContentForm
|
||||||
|
saving: boolean
|
||||||
|
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||||
|
}>()
|
||||||
|
|
||||||
const store = useAwardsStore()
|
const store = useAwardsStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const localSaving = ref(false)
|
||||||
const statusMessage = ref('')
|
const statusMessage = ref('')
|
||||||
const statusError = ref('')
|
const statusError = ref('')
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewHtml = computed(() => privacyContentToHtml(props.form.sponsorsContent))
|
||||||
|
const previewTitle = 'Sponsoren & Partner'
|
||||||
const editingSponsorId = ref<number | null>(null)
|
const editingSponsorId = ref<number | null>(null)
|
||||||
|
const selectedShowactId = ref<number | null>(null)
|
||||||
const showactNotes = reactive<Record<number, string>>({})
|
const showactNotes = reactive<Record<number, string>>({})
|
||||||
const settingsForm = reactive({
|
const settingsForm = reactive({
|
||||||
showactApplicationsEnabled: false,
|
showactApplicationsEnabled: false,
|
||||||
|
showactApplicationStartsAt: '',
|
||||||
|
showactApplicationEndsAt: '',
|
||||||
|
showactApplicationsOpenNow: false,
|
||||||
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
|
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
|
||||||
sponsorsVisible: true,
|
sponsorsVisible: true,
|
||||||
})
|
})
|
||||||
@@ -33,17 +52,108 @@ const sponsorForm = reactive<UpsertSponsorPayload>({
|
|||||||
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
|
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
|
||||||
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length)
|
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length)
|
||||||
const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length)
|
const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length)
|
||||||
|
const selectedShowact = computed(() =>
|
||||||
|
selectedShowactId.value == null
|
||||||
|
? null
|
||||||
|
: store.adminShowactApplications.find((item) => item.id === selectedShowactId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showactSubmissionDetails = computed(() => {
|
||||||
|
const application = selectedShowact.value
|
||||||
|
if (!application) return []
|
||||||
|
|
||||||
|
const schemaFields = props.form.showactFormSchema ?? []
|
||||||
|
const responseEntries = parseShowactResponses(application.fieldResponsesJson)
|
||||||
|
const responseMap = new Map(responseEntries)
|
||||||
|
const consumedKeys = new Set<string>()
|
||||||
|
|
||||||
|
const orderedFields = schemaFields
|
||||||
|
.map((field) => {
|
||||||
|
const value = normalizeDetailValue(responseMap.get(field.id))
|
||||||
|
consumedKeys.add(field.id)
|
||||||
|
if (!value) return null
|
||||||
|
return {
|
||||||
|
key: field.id,
|
||||||
|
label: field.label,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is { key: string; label: string; value: string } => item !== null)
|
||||||
|
|
||||||
|
const remainingFields = responseEntries
|
||||||
|
.filter(([key]) => !consumedKeys.has(key))
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: humanizeShowactKey(key),
|
||||||
|
value: normalizeDetailValue(value),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.value)
|
||||||
|
|
||||||
|
if (orderedFields.length > 0 || remainingFields.length > 0) {
|
||||||
|
return [...orderedFields, ...remainingFields]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'artistName', label: 'Artist', value: application.artistName },
|
||||||
|
{ key: 'performanceType', label: 'Performance', value: application.performanceType },
|
||||||
|
{ key: 'description', label: 'Beschreibung', value: application.description },
|
||||||
|
{ key: 'technicalNotes', label: 'Technische Hinweise', value: application.technicalNotes },
|
||||||
|
{ key: 'platformUrl', label: 'Kanal oder Profil', value: application.platformUrl },
|
||||||
|
{ key: 'referenceUrl', label: 'Referenz', value: application.referenceUrl },
|
||||||
|
{ key: 'contactEmail', label: 'E-Mail', value: application.contactEmail },
|
||||||
|
{ key: 'contactDiscord', label: 'Discord', value: application.contactDiscord },
|
||||||
|
].filter((item) => normalizeDetailValue(item.value))
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.adminOptionalFeatureSettings,
|
() => store.adminOptionalFeatureSettings,
|
||||||
(settings) => {
|
(settings) => {
|
||||||
settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled
|
settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled
|
||||||
|
settingsForm.showactApplicationStartsAt = settings.showactApplicationStartsAt ?? ''
|
||||||
|
settingsForm.showactApplicationEndsAt = settings.showactApplicationEndsAt ?? ''
|
||||||
|
settingsForm.showactApplicationsOpenNow = settings.showactApplicationsOpenNow
|
||||||
settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.'
|
settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.'
|
||||||
settingsForm.sponsorsVisible = settings.sponsorsVisible
|
settingsForm.sponsorsVisible = settings.sponsorsVisible
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true },
|
{ immediate: true, deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showactStatusText = computed(() => {
|
||||||
|
if (!settingsForm.showactApplicationsEnabled) {
|
||||||
|
return 'Bewerbungen sind manuell deaktiviert und es wird nur der Hinweistext ausgespielt.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
if (settingsForm.showactApplicationStartsAt) {
|
||||||
|
parts.push(`Start ${formatDate(settingsForm.showactApplicationStartsAt)}`)
|
||||||
|
}
|
||||||
|
if (settingsForm.showactApplicationEndsAt) {
|
||||||
|
parts.push(`Deadline ${formatDate(settingsForm.showactApplicationEndsAt)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsForm.showactApplicationsOpenNow) {
|
||||||
|
return parts.length > 0
|
||||||
|
? `Bewerbungen sind aktuell offen. ${parts.join(' · ')}.`
|
||||||
|
: 'Bewerbungen sind aktuell offen und das Modal bleibt fuer Einreichungen verfuegbar.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return `Bewerbungen sind aktuell geschlossen. ${parts.join(' · ')}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Bewerbungen sind aktuell geschlossen und es wird nur der Hinweistext ausgespielt.'
|
||||||
|
})
|
||||||
|
|
||||||
|
const showactScheduleError = computed(() => {
|
||||||
|
if (!settingsForm.showactApplicationStartsAt || !settingsForm.showactApplicationEndsAt) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsForm.showactApplicationStartsAt > settingsForm.showactApplicationEndsAt
|
||||||
|
? 'Der Starttermin darf nicht nach der Deadline liegen.'
|
||||||
|
: ''
|
||||||
|
})
|
||||||
|
|
||||||
function resetSponsorForm() {
|
function resetSponsorForm() {
|
||||||
editingSponsorId.value = null
|
editingSponsorId.value = null
|
||||||
sponsorForm.name = ''
|
sponsorForm.name = ''
|
||||||
@@ -92,31 +202,41 @@ async function loadLandingExtras() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLandingExtrasSettings() {
|
async function saveAll() {
|
||||||
saving.value = true
|
localSaving.value = true
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
statusError.value = ''
|
statusError.value = ''
|
||||||
try {
|
try {
|
||||||
await store.updateAdminOptionalFeatureSettings({
|
if (showactScheduleError.value) {
|
||||||
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
|
throw new Error(showactScheduleError.value)
|
||||||
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
|
}
|
||||||
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
|
|
||||||
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
|
await Promise.all([
|
||||||
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
|
store.updateAdminOptionalFeatureSettings({
|
||||||
sponsorsVisible: settingsForm.sponsorsVisible,
|
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
|
||||||
})
|
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
|
||||||
statusMessage.value = 'Landingpage-Extras wurden gespeichert.'
|
clipAdminMenuVisible: store.adminOptionalFeatureSettings.clipAdminMenuVisible,
|
||||||
|
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
|
||||||
|
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
|
||||||
|
showactApplicationStartsAt: settingsForm.showactApplicationStartsAt || null,
|
||||||
|
showactApplicationEndsAt: settingsForm.showactApplicationEndsAt || null,
|
||||||
|
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
|
||||||
|
sponsorsVisible: settingsForm.sponsorsVisible,
|
||||||
|
}),
|
||||||
|
props.saveSiteSettings(props.section === 'showacts' ? 'Showact-Inhalt' : 'Sponsoren-Inhalt'),
|
||||||
|
])
|
||||||
|
statusMessage.value = 'Einstellungen gespeichert.'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht gespeichert werden.'
|
statusError.value = error instanceof Error ? error.message : 'Einstellungen konnten nicht gespeichert werden.'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
localSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSponsor() {
|
async function saveSponsor() {
|
||||||
const resolvedSeasonId = await ensureSeasonId()
|
const resolvedSeasonId = await ensureSeasonId()
|
||||||
if (!resolvedSeasonId) return
|
if (!resolvedSeasonId) return
|
||||||
saving.value = true
|
localSaving.value = true
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
statusError.value = ''
|
statusError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -131,14 +251,14 @@ async function saveSponsor() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
|
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
localSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSponsor(sponsor: AdminSponsorItem) {
|
async function deleteSponsor(sponsor: AdminSponsorItem) {
|
||||||
const resolvedSeasonId = await ensureSeasonId()
|
const resolvedSeasonId = await ensureSeasonId()
|
||||||
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
|
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
|
||||||
saving.value = true
|
localSaving.value = true
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
statusError.value = ''
|
statusError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -148,14 +268,14 @@ async function deleteSponsor(sponsor: AdminSponsorItem) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
|
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
localSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateShowactStatus(applicationId: number, status: string) {
|
async function updateShowactStatus(applicationId: number, status: string) {
|
||||||
const resolvedSeasonId = await ensureSeasonId()
|
const resolvedSeasonId = await ensureSeasonId()
|
||||||
if (!resolvedSeasonId) return
|
if (!resolvedSeasonId) return
|
||||||
saving.value = true
|
localSaving.value = true
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
statusError.value = ''
|
statusError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -167,14 +287,14 @@ async function updateShowactStatus(applicationId: number, status: string) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
|
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
localSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteShowact(applicationId: number, artistName: string) {
|
async function deleteShowact(applicationId: number, artistName: string) {
|
||||||
const resolvedSeasonId = await ensureSeasonId()
|
const resolvedSeasonId = await ensureSeasonId()
|
||||||
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
|
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
|
||||||
saving.value = true
|
localSaving.value = true
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
statusError.value = ''
|
statusError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -184,7 +304,7 @@ async function deleteShowact(applicationId: number, artistName: string) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
|
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
localSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,97 +317,151 @@ function statusLabel(status: string) {
|
|||||||
}[status] ?? status
|
}[status] ?? status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
const parsed = new Date(`${value}T00:00:00`)
|
||||||
|
return Number.isNaN(parsed.getTime())
|
||||||
|
? value
|
||||||
|
: parsed.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShowactResponses(raw: string) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return [] as Array<[string, unknown]>
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(parsed)
|
||||||
|
} catch {
|
||||||
|
return [] as Array<[string, unknown]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetailValue(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'Ja' : 'Nein'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeShowactKey(key: string) {
|
||||||
|
return key
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/^./, (char) => char.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function openShowactDetails(applicationId: number) {
|
||||||
|
selectedShowactId.value = applicationId
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShowactDetails() {
|
||||||
|
selectedShowactId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadLandingExtras)
|
onMounted(loadLandingExtras)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card id="content-landing-extras" class="p-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<p class="text-sm text-slate-500">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Landingpage Extras</p>
|
<template v-if="props.section === 'showacts'">
|
||||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Showacts und Sponsoren</h2>
|
{{ store.adminShowactApplications.length }} Bewerbungen · {{ pendingShowacts }} offen
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
</template>
|
||||||
Module, Bewerbungen und Sponsoren direkt dort steuern, wo sie public erscheinen.
|
<template v-else>
|
||||||
</p>
|
{{ store.adminSponsors.length }} Sponsoren · {{ visibleSponsors }} sichtbar
|
||||||
</div>
|
</template>
|
||||||
|
</p>
|
||||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" :disabled="loading" @click="loadLandingExtras">
|
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" :disabled="loading" @click="loadLandingExtras">
|
||||||
<Settings2 class="h-4 w-4" />
|
<Settings2 class="h-4 w-4" />
|
||||||
{{ loading ? 'Laedt ...' : 'Neu laden' }}
|
{{ loading ? 'Laedt ...' : 'Neu laden' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
<!-- Showacts section -->
|
||||||
<div class="rounded-2xl border border-fuchsia-100 bg-fuchsia-50/55 p-4">
|
<template v-if="props.section === 'showacts'">
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.18em] text-fuchsia-600">Showacts</p>
|
|
||||||
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminShowactApplications.length }}</p>
|
|
||||||
<p class="text-sm text-slate-500">{{ pendingShowacts }} offen</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-sky-100 bg-sky-50/55 p-4">
|
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.18em] text-sky-600">Sponsoren</p>
|
|
||||||
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminSponsors.length }}</p>
|
|
||||||
<p class="text-sm text-slate-500">{{ visibleSponsors }} sichtbar</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/55 p-4">
|
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-600">Saison</p>
|
|
||||||
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.overview.year || store.adminSeasonDetail.year || '–' }}</p>
|
|
||||||
<p class="text-sm text-slate-500">Landingpage-Jahr</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="mt-6 grid gap-3 lg:grid-cols-2">
|
|
||||||
<div class="rounded-2xl border border-violet-100 bg-white p-4">
|
<div class="rounded-2xl border border-violet-100 bg-white p-4">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3>
|
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3>
|
||||||
<p class="mt-1 text-sm leading-6 text-slate-500">Public-Formular auf der Landingpage anzeigen.</p>
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
{{ showactStatusText }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AdminSettingsToggle
|
<AdminSettingsToggle
|
||||||
:model-value="settingsForm.showactApplicationsEnabled"
|
:model-value="settingsForm.showactApplicationsEnabled"
|
||||||
label="Showact-Bewerbungen aktivieren"
|
label="Showact-Bewerbungen aktivieren"
|
||||||
active-label="Offen"
|
active-label="Bewerbung offen"
|
||||||
inactive-label="Zu"
|
inactive-label="Bewerbung geschlossen"
|
||||||
@update:model-value="settingsForm.showactApplicationsEnabled = $event"
|
@update:model-value="settingsForm.showactApplicationsEnabled = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-violet-100 bg-white p-4">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<label class="block space-y-2">
|
||||||
<div>
|
<span class="text-sm font-bold text-slate-900">Starttermin (optional)</span>
|
||||||
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
|
<input
|
||||||
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
|
v-model="settingsForm.showactApplicationStartsAt"
|
||||||
</div>
|
type="date"
|
||||||
<AdminSettingsToggle
|
class="h-12 w-full rounded-2xl border border-violet-100 bg-white px-4 text-sm text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||||
:model-value="settingsForm.sponsorsVisible"
|
|
||||||
label="Sponsoren anzeigen"
|
|
||||||
active-label="Sichtbar"
|
|
||||||
inactive-label="Aus"
|
|
||||||
@update:model-value="settingsForm.sponsorsVisible = $event"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-sm font-bold text-slate-900">Deadline (optional)</span>
|
||||||
|
<input
|
||||||
|
v-model="settingsForm.showactApplicationEndsAt"
|
||||||
|
type="date"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-100 bg-white px-4 text-sm text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<label class="mt-4 block space-y-2">
|
<p v-if="showactScheduleError" class="text-sm font-semibold text-rose-700">
|
||||||
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Showact-Bewerbungen</span>
|
{{ showactScheduleError }}
|
||||||
<textarea
|
</p>
|
||||||
v-model="settingsForm.showactApplicationDisabledMessage"
|
|
||||||
rows="3"
|
<label class="block space-y-2">
|
||||||
maxlength="240"
|
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Bewerbungen</span>
|
||||||
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
<textarea
|
||||||
|
v-model="settingsForm.showactApplicationDisabledMessage"
|
||||||
|
rows="3"
|
||||||
|
maxlength="240"
|
||||||
|
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button class="gap-2" :disabled="props.saving || localSaving" @click="saveAll">
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
{{ (props.saving || localSaving) ? 'Speichert ...' : 'Einstellungen speichern' }}
|
||||||
|
</Button>
|
||||||
|
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
|
||||||
|
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminShowactFormBuilder
|
||||||
|
:form="props.form"
|
||||||
|
:saving="props.saving"
|
||||||
|
:save-site-settings="props.saveSiteSettings"
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
|
||||||
<Button class="gap-2" :disabled="saving" @click="saveLandingExtrasSettings">
|
|
||||||
<Save class="h-4 w-4" />
|
|
||||||
{{ saving ? 'Speichert ...' : 'Landingpage Extras speichern' }}
|
|
||||||
</Button>
|
|
||||||
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
|
|
||||||
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="mt-7 grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)]">
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
||||||
<div class="border-b border-violet-100 p-4">
|
<div class="border-b border-violet-100 p-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -309,7 +483,7 @@ onMounted(loadLandingExtras)
|
|||||||
<p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p>
|
<p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button size="sm" variant="ghost" :disabled="saving" @click="updateShowactStatus(application.id, 'shortlisted')">Shortlist</Button>
|
<Button size="sm" variant="ghost" :disabled="saving" @click="openShowactDetails(application.id)">Details</Button>
|
||||||
<Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button>
|
<Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button>
|
||||||
<Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button>
|
<Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button>
|
||||||
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)">
|
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)">
|
||||||
@@ -317,13 +491,28 @@ onMounted(loadLandingExtras)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
|
|
||||||
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
|
<!-- Dynamic field responses if available, otherwise legacy fixed fields -->
|
||||||
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
|
<template v-if="application.fieldResponsesJson && application.fieldResponsesJson !== '{}'">
|
||||||
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
|
<div class="grid gap-2 text-sm text-slate-600 md:grid-cols-2">
|
||||||
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
|
<template v-for="(value, key) in (() => { try { return JSON.parse(application.fieldResponsesJson) } catch { return {} } })()" :key="key">
|
||||||
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
|
<div v-if="value" class="flex flex-col gap-0.5">
|
||||||
</div>
|
<span class="text-xs font-bold uppercase tracking-wide text-slate-400">{{ key }}</span>
|
||||||
|
<span>{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
|
||||||
|
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
|
||||||
|
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
|
||||||
|
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
|
||||||
|
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
|
||||||
|
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="showactNotes[application.id]"
|
v-model="showactNotes[application.id]"
|
||||||
rows="2"
|
rows="2"
|
||||||
@@ -334,6 +523,47 @@ onMounted(loadLandingExtras)
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sponsors section -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
|
||||||
|
</div>
|
||||||
|
<AdminSettingsToggle
|
||||||
|
:model-value="settingsForm.sponsorsVisible"
|
||||||
|
label="Sponsoren anzeigen"
|
||||||
|
active-label="Sichtbar"
|
||||||
|
inactive-label="Aus"
|
||||||
|
@update:model-value="settingsForm.sponsorsVisible = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="previewOpen = true">
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
Preview öffnen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AdminRichTextEditor
|
||||||
|
v-model="props.form.sponsorsContent"
|
||||||
|
label="Sponsoren-Seiteninhalt"
|
||||||
|
placeholder="Texte für die Sponsoren-Seite (wird als Modal auf der Landingpage angezeigt)..."
|
||||||
|
min-height-class="min-h-[220px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button class="gap-2" :disabled="props.saving || localSaving" @click="saveAll">
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
{{ (props.saving || localSaving) ? 'Speichert ...' : 'Einstellungen speichern' }}
|
||||||
|
</Button>
|
||||||
|
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
|
||||||
|
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
||||||
<div class="border-b border-violet-100 p-4">
|
<div class="border-b border-violet-100 p-4">
|
||||||
@@ -388,6 +618,54 @@ onMounted(loadLandingExtras)
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</template>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
|
<AdminContentFooterPreviewModal
|
||||||
|
:open="previewOpen"
|
||||||
|
:title="previewTitle"
|
||||||
|
url=""
|
||||||
|
:content-html="previewHtml"
|
||||||
|
@close="previewOpen = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
:open="selectedShowact !== null"
|
||||||
|
size="xl"
|
||||||
|
:title="selectedShowact ? `${selectedShowact.artistName} · Showact-Details` : 'Showact-Details'"
|
||||||
|
:subtitle="selectedShowact ? `${statusLabel(selectedShowact.status)} · Eingereicht am ${new Date(selectedShowact.createdAt).toLocaleString('de-DE')}` : undefined"
|
||||||
|
@close="closeShowactDetails"
|
||||||
|
>
|
||||||
|
<div v-if="selectedShowact" class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Artist</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.artistName }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ statusLabel(selectedShowact.status) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">E-Mail</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.contactEmail || 'Nicht angegeben' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Discord</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.contactDiscord || 'Nicht angegeben' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="detail in showactSubmissionDetails"
|
||||||
|
:key="detail.key"
|
||||||
|
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{{ detail.label }}</p>
|
||||||
|
<p class="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-slate-800">{{ detail.value }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="previewOpen = true">
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
Preview öffnen
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
{{ saving ? 'Speichert ...' : `${props.label} speichern` }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AdminRichTextEditor
|
||||||
|
v-model="form[props.contentKey]"
|
||||||
|
:label="props.label"
|
||||||
|
:placeholder="props.placeholder"
|
||||||
|
min-height-class="min-h-[420px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminContentFooterPreviewModal
|
||||||
|
:open="previewOpen"
|
||||||
|
:title="props.label"
|
||||||
|
url=""
|
||||||
|
:content-html="previewHtml"
|
||||||
|
@close="previewOpen = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Eye, Save } from '@lucide/vue'
|
||||||
|
|
||||||
|
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import AdminContentFooterPreviewModal from './AdminContentFooterPreviewModal.vue'
|
||||||
|
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
||||||
|
import type { AdminContentForm } from './adminContentTypes'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: AdminContentForm
|
||||||
|
saving: boolean
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
contentKey: 'imprintContent' | 'contactContent' | 'sponsorsContent'
|
||||||
|
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewHtml = computed(() => privacyContentToHtml(props.form[props.contentKey]))
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
return props.saveSiteSettings(props.label)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -31,9 +31,13 @@
|
|||||||
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
|
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="mt-6 block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz E-Mail</span>
|
||||||
|
<input v-model="form.privacyEmail" type="email" placeholder="datenschutz@..." class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
<AdminRichTextEditor
|
<AdminRichTextEditor
|
||||||
v-model="form.privacyPolicyContent"
|
v-model="form.privacyPolicyContent"
|
||||||
class="mt-6"
|
class="mt-4"
|
||||||
label="Datenschutz Inhalt"
|
label="Datenschutz Inhalt"
|
||||||
placeholder="Datenschutztext..."
|
placeholder="Datenschutztext..."
|
||||||
min-height-class="min-h-[560px]"
|
min-height-class="min-h-[560px]"
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { KeyRound } from '@lucide/vue'
|
||||||
|
|
||||||
|
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
|
||||||
|
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||||
|
import PasswordField from '../ui/PasswordField.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
form: AdminOperationalSettingsForm
|
||||||
|
saving: boolean
|
||||||
|
canManage: boolean
|
||||||
|
demoPassword: string
|
||||||
|
demoPasswordHint: string
|
||||||
|
demoPasswordSet: boolean
|
||||||
|
demoManagedByDatabase: boolean
|
||||||
|
demoCredentialsComplete: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:demoPassword': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<KeyRound class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Demo Login</p>
|
||||||
|
<h3 class="mt-1 text-xl font-bold text-slate-900">Landingpage temporär sperren</h3>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Für Demo-Deployments oder nicht öffentliche Tests. Danach kann der Schutz hier wieder aus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminSettingsToggle
|
||||||
|
v-model="form.demoLoginEnabled"
|
||||||
|
label="Demo Login aktivieren"
|
||||||
|
:disabled="saving || !canManage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="form.demoLoginEnabled && !demoCredentialsComplete"
|
||||||
|
class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
|
||||||
|
>
|
||||||
|
Zum Aktivieren fehlen noch Login, Twitch-ID, Anzeigename oder ein gesetztes Passwort.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Login</span>
|
||||||
|
<input v-model="form.demoLoginIdentifier" :disabled="saving || !canManage" type="text" autocomplete="username" placeholder="E-Mail oder Username" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||||
|
<span class="mt-2 block text-xs leading-5 text-slate-500">E-Mail, Username oder dieselbe Kennung wie die Twitch-ID.</span>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
|
||||||
|
<PasswordField
|
||||||
|
:model-value="demoPassword"
|
||||||
|
:disabled="saving || !canManage"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Leer lassen = behalten"
|
||||||
|
root-class="mt-2"
|
||||||
|
input-class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
|
||||||
|
@update:model-value="emit('update:demoPassword', $event)"
|
||||||
|
/>
|
||||||
|
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Twitch-ID</span>
|
||||||
|
<input v-model="form.demoLoginTwitchUserId" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||||
|
<input v-model="form.demoLoginDisplayName" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2 text-xs font-semibold">
|
||||||
|
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">{{ demoManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}</span>
|
||||||
|
<span class="rounded-full px-3 py-1" :class="demoPasswordSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||||
|
{{ demoPasswordSet ? 'Passwort vorhanden' : 'Passwort fehlt' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ShieldCheck, Wrench } from '@lucide/vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
|
||||||
|
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
form: AdminOperationalSettingsForm
|
||||||
|
saving: boolean
|
||||||
|
canManage: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl" :class="form.maintenanceModeEnabled ? 'bg-amber-100 text-amber-700' : 'bg-violet-100 text-violet-700'">
|
||||||
|
<Wrench class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em]" :class="form.maintenanceModeEnabled ? 'text-amber-600' : 'text-violet-500'">Wartungsmodus</p>
|
||||||
|
<h3 class="mt-1 text-xl font-bold text-slate-900">Sternenpause anzeigen</h3>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Besucher landen auf der Wartungsseite. Login und Admin bleiben erreichbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminSettingsToggle
|
||||||
|
v-model="form.maintenanceModeEnabled"
|
||||||
|
label="Wartungsmodus aktivieren"
|
||||||
|
tone="amber"
|
||||||
|
:disabled="saving || !canManage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel der Wartungsseite</span>
|
||||||
|
<input v-model="form.maintenanceTitle" :disabled="saving || !canManage" type="text" maxlength="120" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Hinweistext</span>
|
||||||
|
<textarea v-model="form.maintenanceMessage" :disabled="saving || !canManage" rows="4" maxlength="600" class="mt-2 w-full resize-none rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"></textarea>
|
||||||
|
</label>
|
||||||
|
<RouterLink to="/maintenance?preview=true" class="inline-flex h-11 items-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-700 transition hover:bg-violet-50">
|
||||||
|
<ShieldCheck class="h-4 w-4" />
|
||||||
|
Wartungsseite ansehen
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,52 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted } from 'vue'
|
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { Sparkles, X } from '@lucide/vue'
|
import { Sparkles, X } from '@lucide/vue'
|
||||||
|
|
||||||
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
|
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
|
||||||
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
|
|
||||||
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
|
|
||||||
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
|
|
||||||
import { useAdminReviewsManager } from './useAdminReviewsManager'
|
|
||||||
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
|
nomination: AdminNominationReviewGroup | null
|
||||||
|
reviewSaving: number | null
|
||||||
|
reviewForm: {
|
||||||
|
displayName: string
|
||||||
|
channelSlug: string
|
||||||
|
platform: string
|
||||||
|
categoryId: number | null
|
||||||
|
reviewNote: string
|
||||||
|
} | null
|
||||||
|
candidatePlatformOptions: Array<{ key: string; label: string }>
|
||||||
|
categoryOptions: Array<{ id: number; label: string }>
|
||||||
|
selectedCandidateCollision: AdminCandidateItem | null
|
||||||
|
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
||||||
|
signalSummary?: {
|
||||||
|
submissions: number
|
||||||
|
uniqueSubmitters: number
|
||||||
|
platforms: string[]
|
||||||
|
} | null
|
||||||
|
trackingReviewNotes?: string
|
||||||
|
selectedNominationHasBlockingFlag?: boolean
|
||||||
|
streamUrl?: string
|
||||||
|
canApproveSelected: boolean
|
||||||
|
blacklistSaving: boolean
|
||||||
|
selectedPlatformValue: (platform: string) => string
|
||||||
|
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
|
'platform-change': [value: string]
|
||||||
|
approve: [nominationId: number]
|
||||||
|
reject: [nominationId: number]
|
||||||
|
reviewFlagged: [nominationId: number]
|
||||||
|
overrideFlagged: [nominationId: number]
|
||||||
|
'blacklist-link': [streamUrl: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const {
|
const modalTitle = computed(() => props.nomination?.displayName || 'Nominierung entscheiden')
|
||||||
reviewSaving,
|
|
||||||
blacklistSaving,
|
|
||||||
adminMessage,
|
|
||||||
adminError,
|
|
||||||
reviewForms,
|
|
||||||
seasonDetail,
|
|
||||||
reviewFilter,
|
|
||||||
categoryFilter,
|
|
||||||
selectedNominationId,
|
|
||||||
candidatePlatformOptions,
|
|
||||||
filteredNominations,
|
|
||||||
selectedNomination,
|
|
||||||
reviewStats,
|
|
||||||
reviewedNominations,
|
|
||||||
categoryOptions,
|
|
||||||
selectedCandidateCollision,
|
|
||||||
selectedRelatedPendingNominations,
|
|
||||||
selectedNominationSignalSummary,
|
|
||||||
canApproveSelected,
|
|
||||||
approveNomination,
|
|
||||||
rejectNomination,
|
|
||||||
addStreamUrlToBlacklist,
|
|
||||||
selectedPlatformValue,
|
|
||||||
handlePlatformSelection,
|
|
||||||
extractNominationStreamUrl,
|
|
||||||
} = useAdminReviewsManager()
|
|
||||||
|
|
||||||
watchAdminToast(adminMessage, adminError)
|
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -84,7 +83,7 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="mt-3 text-2xl font-bold text-slate-950">Nominierung entscheiden</h2>
|
<h2 class="mt-3 text-2xl font-bold text-slate-950">{{ modalTitle }}</h2>
|
||||||
<p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
|
<p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme.
|
Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme.
|
||||||
</p>
|
</p>
|
||||||
@@ -99,83 +98,30 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="min-h-0 overflow-y-auto bg-white">
|
<div class="min-h-0 overflow-y-auto p-4 sm:p-6">
|
||||||
<AdminReviewsQueueHeader
|
<AdminReviewDecisionPanel
|
||||||
v-model:review-filter="reviewFilter"
|
:nomination="nomination"
|
||||||
v-model:category-filter="categoryFilter"
|
:review-saving="reviewSaving"
|
||||||
:total-pending="seasonDetail.pendingNominations.length"
|
:review-form="reviewForm"
|
||||||
:visible-count="filteredNominations.length"
|
:candidate-platform-options="candidatePlatformOptions"
|
||||||
:review-stats="reviewStats"
|
|
||||||
:category-options="categoryOptions"
|
:category-options="categoryOptions"
|
||||||
|
:selected-candidate-collision="selectedCandidateCollision"
|
||||||
|
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||||
|
:signal-summary="signalSummary"
|
||||||
|
:tracking-review-notes="trackingReviewNotes"
|
||||||
|
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
|
||||||
|
:stream-url="streamUrl"
|
||||||
|
:can-approve-selected="canApproveSelected"
|
||||||
|
:blacklist-saving="blacklistSaving"
|
||||||
|
:selected-platform-value="selectedPlatformValue"
|
||||||
|
:tracker-status-meta="trackerStatusMeta"
|
||||||
|
@platform-change="emit('platform-change', $event)"
|
||||||
|
@approve="emit('approve', $event)"
|
||||||
|
@reject="emit('reject', $event)"
|
||||||
|
@review-flagged="emit('reviewFlagged', $event)"
|
||||||
|
@override-flagged="emit('overrideFlagged', $event)"
|
||||||
|
@blacklist-link="emit('blacklist-link', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="space-y-4 p-4 sm:p-6">
|
|
||||||
<div class="grid gap-5 xl:grid-cols-[minmax(300px,0.8fr)_minmax(0,1.2fr)]">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<!-- Keyboard shortcut legend — fixed above scrollable list -->
|
|
||||||
<div v-if="filteredNominations.length > 0" class="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-2xl border border-violet-100 bg-violet-50/60 px-3 py-2">
|
|
||||||
<span class="text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">Tastaturkürzel</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<span class="inline-flex gap-0.5">
|
|
||||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">↑</kbd>
|
|
||||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">↓</kbd>
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] text-slate-500">/ </span>
|
|
||||||
<span class="inline-flex gap-0.5">
|
|
||||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">J</kbd>
|
|
||||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">K</kbd>
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] text-slate-500">Navigieren</span>
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-emerald-200 bg-emerald-50 px-1.5 text-[10px] font-bold text-emerald-700 shadow-sm">A</kbd>
|
|
||||||
<span class="text-[11px] text-slate-500">Annehmen</span>
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-rose-200 bg-rose-50 px-1.5 text-[10px] font-bold text-rose-700 shadow-sm">R</kbd>
|
|
||||||
<span class="text-[11px] text-slate-500">Ablehnen</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminReviewsQueueList
|
|
||||||
:nominations="filteredNominations"
|
|
||||||
:total-pending="seasonDetail.pendingNominations.length"
|
|
||||||
:selected-nomination-id="selectedNominationId"
|
|
||||||
@select="selectedNominationId = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminReviewDecisionPanel
|
|
||||||
:nomination="selectedNomination"
|
|
||||||
:review-saving="reviewSaving"
|
|
||||||
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
|
|
||||||
:candidate-platform-options="candidatePlatformOptions"
|
|
||||||
:selected-candidate-collision="selectedCandidateCollision"
|
|
||||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
|
||||||
:signal-summary="selectedNominationSignalSummary"
|
|
||||||
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
|
||||||
:can-approve-selected="canApproveSelected"
|
|
||||||
:blacklist-saving="blacklistSaving"
|
|
||||||
:selected-platform-value="selectedPlatformValue"
|
|
||||||
@platform-change="handlePlatformSelection"
|
|
||||||
@approve="approveNomination"
|
|
||||||
@reject="rejectNomination"
|
|
||||||
@blacklist-link="addStreamUrlToBlacklist"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="rounded-[24px] border border-violet-100 bg-violet-50/40">
|
|
||||||
<summary class="cursor-pointer px-5 py-4 text-sm font-semibold text-violet-900">
|
|
||||||
Letzte Entscheidungen anzeigen
|
|
||||||
</summary>
|
|
||||||
<div class="border-t border-violet-100 p-5">
|
|
||||||
<AdminReviewsHistorySection
|
|
||||||
:reviewed-nominations="reviewedNominations"
|
|
||||||
:reviewed-total="seasonDetail.reviewedNominations.length"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue'
|
import { Clock3, KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||||
@@ -225,6 +225,39 @@ function toneClasses(tone: AdminSettingsTone) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<Clock3 class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Session Timeout</p>
|
||||||
|
<h3 class="mt-1 text-xl font-bold text-slate-900">Automatischer Logout bei Inaktivitaet</h3>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Team- und Admin-Sessions werden nach der eingestellten Zeit ohne Aktivitaet automatisch beendet. Minimum sind 3 Stunden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-[minmax(0,220px)_1fr] md:items-start">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Inaktiv nach Stunden</span>
|
||||||
|
<input
|
||||||
|
v-model.number="form.sessionIdleTimeoutHours"
|
||||||
|
:disabled="saving || !canManage"
|
||||||
|
type="number"
|
||||||
|
min="3"
|
||||||
|
step="1"
|
||||||
|
inputmode="numeric"
|
||||||
|
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
||||||
|
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
|
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Save } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import Card from '../ui/Card.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
form: {
|
||||||
|
clipSubmissionsEnabled: boolean
|
||||||
|
clipReviewEnabled: boolean
|
||||||
|
clipAdminMenuVisible: boolean
|
||||||
|
clipSubmissionDisabledMessage: string
|
||||||
|
}
|
||||||
|
saving: boolean
|
||||||
|
dirty: boolean
|
||||||
|
canManage: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: []
|
||||||
|
'update:clipSubmissionsEnabled': [value: boolean]
|
||||||
|
'update:clipReviewEnabled': [value: boolean]
|
||||||
|
'update:clipAdminMenuVisible': [value: boolean]
|
||||||
|
'update:clipSubmissionDisabledMessage': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<section class="border-b border-violet-100 p-5">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Optionale Workflows</p>
|
||||||
|
<h2 class="mt-2 text-lg font-bold text-slate-900">Clip-Einreichungen direkt steuern</h2>
|
||||||
|
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||||
|
Clip-Einreichung und Clip-Review sind optionale Betriebsfunktionen. Wenn deaktiviert, bleibt der Award-Workflow ohne Clip-Pflicht nutzbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button class="gap-2" :disabled="saving || !dirty || !canManage" @click="emit('save')">
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
{{ saving ? 'Speichert ...' : 'Clip-Workflow speichern' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!canManage" class="border-b border-amber-100 bg-amber-50 px-5 py-3 text-sm font-semibold text-amber-800">
|
||||||
|
Du kannst den Clip-Workflow ansehen, aber nicht bearbeiten.
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-5 p-5">
|
||||||
|
<section class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-base font-bold text-slate-900">Public-Einreichung</h4>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Besucher können während der passenden Phase Clips einreichen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminSettingsToggle
|
||||||
|
:model-value="form.clipSubmissionsEnabled"
|
||||||
|
label="Clip-Einreichung aktivieren"
|
||||||
|
:disabled="!canManage"
|
||||||
|
active-label="Aktiv"
|
||||||
|
inactive-label="Inaktiv"
|
||||||
|
@update:model-value="emit('update:clipSubmissionsEnabled', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-base font-bold text-slate-900">Admin-Review</h4>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Clip-Bestand und Alt-Einreichungen bleiben als optionale Inbox sichtbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminSettingsToggle
|
||||||
|
:model-value="form.clipReviewEnabled"
|
||||||
|
label="Admin-Review anzeigen"
|
||||||
|
:disabled="!canManage"
|
||||||
|
active-label="Sichtbar"
|
||||||
|
inactive-label="Aus"
|
||||||
|
@update:model-value="emit('update:clipReviewEnabled', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-base font-bold text-slate-900">Menüpunkt „Clips" im Admin</h4>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Blendet den Clips-Menüpunkt im Admin-Panel ein oder aus, unabhängig von Einreichung und Review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminSettingsToggle
|
||||||
|
:model-value="form.clipAdminMenuVisible"
|
||||||
|
label="Clips-Menüpunkt anzeigen"
|
||||||
|
:disabled="!canManage"
|
||||||
|
active-label="Sichtbar"
|
||||||
|
inactive-label="Aus"
|
||||||
|
@update:model-value="emit('update:clipAdminMenuVisible', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-sm font-bold text-slate-900">Hinweis bei deaktivierter Einreichung</span>
|
||||||
|
<textarea
|
||||||
|
:value="form.clipSubmissionDisabledMessage"
|
||||||
|
rows="3"
|
||||||
|
maxlength="240"
|
||||||
|
:disabled="!canManage"
|
||||||
|
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
|
||||||
|
@input="emit('update:clipSubmissionDisabledMessage', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-semibold text-slate-500">
|
||||||
|
{{ form.clipSubmissionDisabledMessage.length }}/240 Zeichen
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-800">
|
||||||
|
Nominierung, Voting und Gewinnerpflege laufen unabhängig davon weiter. Bestehende Clips werden nicht gelöscht.
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -1,16 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { BookOpenText, CheckCircle2, History, Info, Rocket, ShieldCheck, Sparkles, Tag } from '@lucide/vue'
|
import { BookOpenText, CheckCircle2, ChevronDown, History, Info, Rocket, ShieldCheck, Sparkles, Tag } from '@lucide/vue'
|
||||||
|
|
||||||
import { buildDateLabel, buildVersion, releaseNotes, semanticVersion, versionRules, type ReleaseChangeType } from '../../lib/releaseNotes'
|
import { buildDateLabel, buildVersion, releaseNotes, releaseVersions, semanticVersion, versionRules, type ReleaseChangeType } from '../../lib/releaseNotes'
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import Card from '../ui/Card.vue'
|
import Card from '../ui/Card.vue'
|
||||||
import Modal from '../ui/Modal.vue'
|
import Modal from '../ui/Modal.vue'
|
||||||
|
|
||||||
const changelogOpen = ref(false)
|
const changelogOpen = ref(false)
|
||||||
|
const expandedVersions = ref<string[]>([releaseVersions[0]?.version ?? semanticVersion])
|
||||||
|
|
||||||
const featureCount = computed(() => releaseNotes.filter((note) => note.type === 'feature').length)
|
const featureCount = computed(() => releaseNotes.filter((note) => note.type === 'feature').length)
|
||||||
const fixCount = computed(() => releaseNotes.filter((note) => note.type === 'fix').length)
|
const fixCount = computed(() => releaseNotes.filter((note) => note.type === 'fix').length)
|
||||||
|
const versionCount = computed(() => releaseVersions.length)
|
||||||
|
|
||||||
|
function versionFeatureCount(version: typeof releaseVersions[number]) {
|
||||||
|
return version.notes.filter((note) => note.type === 'feature').length
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionFixCount(version: typeof releaseVersions[number]) {
|
||||||
|
return version.notes.filter((note) => note.type === 'fix').length
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVersionExpanded(version: string) {
|
||||||
|
return expandedVersions.value.includes(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVersion(version: string) {
|
||||||
|
if (isVersionExpanded(version)) {
|
||||||
|
expandedVersions.value = expandedVersions.value.filter((entry) => entry !== version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedVersions.value = [version]
|
||||||
|
}
|
||||||
|
|
||||||
function changeTypeLabel(type: ReleaseChangeType) {
|
function changeTypeLabel(type: ReleaseChangeType) {
|
||||||
if (type === 'major') return 'Major'
|
if (type === 'major') return 'Major'
|
||||||
@@ -83,6 +106,11 @@ function changeTypeClasses(type: ReleaseChangeType) {
|
|||||||
<strong class="mt-1 block text-lg text-amber-700">{{ fixCount }}</strong>
|
<strong class="mt-1 block text-lg text-amber-700">{{ fixCount }}</strong>
|
||||||
<p class="mt-1 text-xs leading-5 text-slate-600">Stabilitaet, UI-Feedback und Backend-Hygiene.</p>
|
<p class="mt-1 text-xs leading-5 text-slate-600">Stabilitaet, UI-Feedback und Backend-Hygiene.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-3 md:col-span-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Versionen im Verlauf</p>
|
||||||
|
<strong class="mt-1 block text-lg text-slate-800">{{ versionCount }}</strong>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-slate-600">Neueste Builds lassen sich im Changelog einzeln auf- und zuklappen.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -115,32 +143,80 @@ function changeTypeClasses(type: ReleaseChangeType) {
|
|||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<History class="h-4 w-4 text-violet-600" />
|
<History class="h-4 w-4 text-violet-600" />
|
||||||
<h3 class="text-sm font-bold text-slate-900">Was neu ist</h3>
|
<h3 class="text-sm font-bold text-slate-900">Versionen & Aenderungen</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-3">
|
<div class="mt-3 space-y-3">
|
||||||
<article
|
<section
|
||||||
v-for="note in releaseNotes"
|
v-for="version in releaseVersions"
|
||||||
:key="note.title"
|
:key="version.version"
|
||||||
class="rounded-2xl border p-4"
|
class="overflow-hidden rounded-[24px] border border-violet-100 bg-white/95 shadow-[0_16px_36px_rgba(124,92,255,0.08)]"
|
||||||
:class="changeTypeClasses(note.type).panel"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<button
|
||||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="changeTypeClasses(note.type).icon">
|
type="button"
|
||||||
<Sparkles v-if="note.type === 'feature'" class="h-4 w-4" />
|
class="flex w-full items-start justify-between gap-4 bg-gradient-to-r from-white via-violet-50/50 to-white px-5 py-4 text-left transition hover:bg-violet-50/70"
|
||||||
<ShieldCheck v-else-if="note.type === 'major'" class="h-4 w-4" />
|
@click="toggleVersion(version.version)"
|
||||||
<CheckCircle2 v-else class="h-4 w-4" />
|
>
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<h4 class="text-sm font-bold text-slate-900">{{ note.title }}</h4>
|
<span class="rounded-full bg-slate-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
|
||||||
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(note.type).badge">
|
{{ version.version }}
|
||||||
{{ changeTypeLabel(note.type) }}
|
</span>
|
||||||
|
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(version.releaseType).badge">
|
||||||
|
{{ changeTypeLabel(version.releaseType) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold text-slate-500">
|
||||||
|
{{ version.buildVersion ?? version.version }} · {{ version.buildDateLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm leading-6 text-slate-600">{{ note.description }}</p>
|
<h4 class="mt-3 text-base font-bold text-slate-900">{{ version.summary }}</h4>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
|
||||||
|
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-violet-700">
|
||||||
|
{{ versionFeatureCount(version) }} Features
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-emerald-700">
|
||||||
|
{{ versionFixCount(version) }} Fixes
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-slate-600">
|
||||||
|
{{ version.notes.length }} Eintraege
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl border border-violet-100 bg-white text-violet-700 transition"
|
||||||
|
:class="{ 'rotate-180': isVersionExpanded(version.version) }"
|
||||||
|
>
|
||||||
|
<ChevronDown class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="isVersionExpanded(version.version)" class="border-t border-violet-100 bg-white px-5 py-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="note in version.notes"
|
||||||
|
:key="`${version.version}-${note.title}`"
|
||||||
|
class="rounded-2xl border p-4"
|
||||||
|
:class="changeTypeClasses(note.type).panel"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="changeTypeClasses(note.type).icon">
|
||||||
|
<Sparkles v-if="note.type === 'feature'" class="h-4 w-4" />
|
||||||
|
<ShieldCheck v-else-if="note.type === 'major'" class="h-4 w-4" />
|
||||||
|
<CheckCircle2 v-else class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 class="text-sm font-bold text-slate-900">{{ note.title }}</h4>
|
||||||
|
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(note.type).badge">
|
||||||
|
{{ changeTypeLabel(note.type) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-600">{{ note.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Ban, CheckCircle2, Loader2, Trash2 } from '@lucide/vue'
|
import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
|
||||||
|
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import NativeSelect from '../ui/NativeSelect.vue'
|
import NativeSelect from '../ui/NativeSelect.vue'
|
||||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
nomination: AdminNominationReviewItem | null
|
nomination: AdminNominationReviewGroup | null
|
||||||
reviewSaving: number | null
|
reviewSaving: number | null
|
||||||
reviewForm: {
|
reviewForm: {
|
||||||
displayName: string
|
displayName: string
|
||||||
channelSlug: string
|
channelSlug: string
|
||||||
platform: string
|
platform: string
|
||||||
|
categoryId: number | null
|
||||||
reviewNote: string
|
reviewNote: string
|
||||||
} | null
|
} | null
|
||||||
candidatePlatformOptions: Array<{ key: string; label: string }>
|
candidatePlatformOptions: Array<{ key: string; label: string }>
|
||||||
|
categoryOptions: Array<{ id: number; label: string }>
|
||||||
selectedCandidateCollision: AdminCandidateItem | null
|
selectedCandidateCollision: AdminCandidateItem | null
|
||||||
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
||||||
signalSummary?: {
|
signalSummary?: {
|
||||||
@@ -22,152 +24,291 @@ defineProps<{
|
|||||||
uniqueSubmitters: number
|
uniqueSubmitters: number
|
||||||
platforms: string[]
|
platforms: string[]
|
||||||
} | null
|
} | null
|
||||||
|
trackingReviewNotes?: string
|
||||||
|
selectedNominationHasBlockingFlag?: boolean
|
||||||
streamUrl?: string
|
streamUrl?: string
|
||||||
canApproveSelected: boolean
|
canApproveSelected: boolean
|
||||||
blacklistSaving: boolean
|
blacklistSaving: boolean
|
||||||
selectedPlatformValue: (platform: string) => string
|
selectedPlatformValue: (platform: string) => string
|
||||||
|
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'platform-change': [value: string]
|
'platform-change': [value: string]
|
||||||
approve: [nominationId: number]
|
approve: [nominationId: number]
|
||||||
reject: [nominationId: number]
|
reject: [nominationId: number]
|
||||||
|
reviewFlagged: [nominationId: number]
|
||||||
|
overrideFlagged: [nominationId: number]
|
||||||
'blacklist-link': [streamUrl: string]
|
'blacklist-link': [streamUrl: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||||
|
return nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')?.windowLabel?.trim() ?? ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
|
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-4 shadow-[0_14px_36px_rgba(168,145,214,0.08)] sm:p-5">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div class="space-y-4">
|
||||||
<div class="min-w-0">
|
<section class="rounded-[24px] border border-violet-100 bg-[linear-gradient(135deg,#fcfaff_0%,#ffffff_100%)] p-4 sm:p-5">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText || 'Name im Review festlegen' }}</h3>
|
<div class="min-w-0">
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
<span class="rounded-full bg-violet-100 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-violet-800">
|
||||||
</p>
|
{{ nomination.categoryGroupName }}
|
||||||
</div>
|
</span>
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
|
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500">
|
||||||
ID {{ nomination.id }}
|
Gruppe #{{ nomination.id }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 class="mt-3 break-words text-xl font-bold text-slate-900 sm:text-2xl">{{ nomination.displayName || 'Name im Review festlegen' }}</h3>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende · {{ nomination.nominationTally }} Nominierungen · zuletzt {{ new Date(nomination.lastSubmittedAt).toLocaleString('de-DE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
<div
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
|
class="rounded-2xl border px-4 py-3 text-sm"
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
:class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
|
||||||
<a
|
>
|
||||||
:href="streamUrl"
|
<p class="text-[10px] font-bold uppercase tracking-[0.18em]">Tracker</p>
|
||||||
target="_blank"
|
<p class="mt-1 font-semibold">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</p>
|
||||||
rel="noopener"
|
</div>
|
||||||
class="inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
</div>
|
||||||
>
|
|
||||||
<span class="truncate">{{ streamUrl }}</span>
|
|
||||||
</a>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2 rounded-full border-rose-200 bg-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
|
|
||||||
:disabled="blacklistSaving"
|
|
||||||
@click="emit('blacklist-link', streamUrl)"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
|
|
||||||
<Ban v-else class="h-3.5 w-3.5" />
|
|
||||||
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
|
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Einreichungen</p>
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tier-Vorschlag</p>
|
||||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
|
<strong class="mt-1 block text-sm text-violet-900">{{ nomination.suggestedCategoryName || 'manuell wählen' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige User</p>
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Avg Viewer <span v-if="avgViewerWindowLabel(nomination)">· {{ avgViewerWindowLabel(nomination) }}</span></p>
|
||||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
|
<strong class="mt-1 block text-xl text-violet-900">{{ nomination.avgViewers ?? 'offen' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
|
||||||
<strong class="mt-1 block truncate text-sm text-violet-900">{{ signalSummary.platforms.join(', ') || 'keine' }}</strong>
|
<strong class="mt-1 block text-sm text-violet-900">{{ signalSummary?.platforms.join(', ') || nomination.resolvedPlatform || 'keine' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
|
<div class="space-y-4">
|
||||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
<section class="rounded-[24px] border border-violet-100 bg-violet-50/35 p-4">
|
||||||
<label class="space-y-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
<Link2 class="h-4 w-4 text-sky-700" />
|
||||||
<input
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Quelle & Signale</p>
|
||||||
v-model="reviewForm.displayName"
|
</div>
|
||||||
type="text"
|
|
||||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
|
||||||
placeholder="Anzeigename"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="space-y-2">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
|
||||||
<input
|
|
||||||
v-model="reviewForm.channelSlug"
|
|
||||||
type="text"
|
|
||||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
|
||||||
placeholder="channel"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="space-y-2">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
|
||||||
<NativeSelect
|
|
||||||
:model-value="selectedPlatformValue(reviewForm.platform)"
|
|
||||||
:options="[
|
|
||||||
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
|
|
||||||
{ label: 'Eigene Plattform', value: 'custom' },
|
|
||||||
]"
|
|
||||||
@update:model-value="emit('platform-change', String($event))"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
|
||||||
<input
|
|
||||||
v-model="reviewForm.platform"
|
|
||||||
type="text"
|
|
||||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
|
||||||
placeholder="z. B. Cake, Booth, neue Plattform"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
||||||
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
|
|
||||||
</p>
|
|
||||||
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
||||||
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
|
|
||||||
<ul class="mt-2 space-y-1">
|
|
||||||
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
|
|
||||||
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
|
||||||
Anzeigename, Handle und Plattform sind Pflicht, damit der Kandidat später im Voting eindeutig erscheint.
|
|
||||||
</p>
|
|
||||||
<label class="mt-3 block space-y-2">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
|
|
||||||
<textarea
|
|
||||||
v-model="reviewForm.reviewNote"
|
|
||||||
rows="3"
|
|
||||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
|
||||||
placeholder="Warum wurde übernommen oder verworfen?"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
<div v-if="streamUrl" class="mt-3 rounded-2xl border border-sky-100 bg-white p-3">
|
||||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="emit('reject', nomination.id)">
|
<a
|
||||||
<Trash2 class="mr-2 h-4 w-4" />
|
:href="streamUrl"
|
||||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
target="_blank"
|
||||||
</Button>
|
rel="noopener"
|
||||||
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" @click="emit('approve', nomination.id)">
|
class="block truncate text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
||||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
>
|
||||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
{{ streamUrl }}
|
||||||
</Button>
|
</a>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="gap-2 rounded-full border-rose-200 bg-rose-50 px-3 text-rose-700 hover:bg-rose-100 hover:text-rose-800"
|
||||||
|
:disabled="blacklistSaving"
|
||||||
|
@click="emit('blacklist-link', streamUrl)"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<Ban v-else class="h-3.5 w-3.5" />
|
||||||
|
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="signalSummary" class="mt-3 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Nominierungen</p>
|
||||||
|
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige Nominierende</p>
|
||||||
|
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</p>
|
||||||
|
<strong class="mt-1 block text-sm text-violet-900">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50/75 p-4">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">Nominierungen dieser Gruppe</p>
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="related in selectedRelatedPendingNominations"
|
||||||
|
:key="related.id"
|
||||||
|
class="rounded-2xl border border-amber-100 bg-white px-3 py-2 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
<p class="font-semibold">#{{ related.id }} · {{ related.submittedByTwitchId }}</p>
|
||||||
|
<p class="mt-1 break-words text-slate-500">{{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="rounded-[24px] border p-4"
|
||||||
|
:class="nomination.trackingReviewStatus === 'overridden' ? 'border-emerald-100 bg-emerald-50/70' : nomination.requiresManualReview ? 'border-amber-100 bg-amber-50/70' : 'border-violet-100 bg-white'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ShieldAlert class="h-4 w-4" :class="nomination.requiresManualReview ? 'text-amber-700' : 'text-violet-600'" />
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em]" :class="nomination.requiresManualReview ? 'text-amber-800' : 'text-violet-500'">Tracking Review</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm font-semibold text-slate-900">{{ props.trackerStatusMeta(nomination.trackerStatus).hint }}</p>
|
||||||
|
|
||||||
|
<div v-if="nomination.trackingFlags.length" class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="flag in nomination.trackingFlags"
|
||||||
|
:key="flag.key"
|
||||||
|
class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-semibold text-amber-800"
|
||||||
|
>
|
||||||
|
{{ flag.label }}<span v-if="flag.blocksApproval"> · blockiert</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="nomination.trackingMetrics.length" class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<div v-for="metric in nomination.trackingMetrics" :key="metric.key" class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">{{ metric.label }}</p>
|
||||||
|
<span
|
||||||
|
class="rounded-full border px-2 py-0.5 text-[10px] font-semibold"
|
||||||
|
:class="metric.sourceSupport === 'auto'
|
||||||
|
? (metric.present ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700')
|
||||||
|
: 'border-slate-200 bg-slate-50 text-slate-700'"
|
||||||
|
>
|
||||||
|
{{ metric.sourceSupport === 'auto' ? (metric.present ? 'vorhanden' : 'fehlt') : (metric.sourceSupport === 'manual' ? 'manuell' : 'kontext') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<strong class="mt-1 block text-sm text-violet-900">{{ metric.value }}</strong>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ metric.required ? 'Pflichtmetrik' : 'Optionale Metrik' }} · {{ metric.windowLabel }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trackingReviewNotes" class="mt-4 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Manual Review Notes</p>
|
||||||
|
<pre class="mt-2 whitespace-pre-wrap text-sm leading-6 text-sky-900">{{ trackingReviewNotes }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<section class="rounded-[24px] border border-violet-100 bg-violet-50/45 p-4 sm:p-5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UserRoundCheck class="h-4 w-4 text-violet-700" />
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Kandidaten-Übernahme</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||||
|
<input
|
||||||
|
v-model="reviewForm.displayName"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Anzeigename"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||||
|
<input
|
||||||
|
v-model="reviewForm.channelSlug"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="channel"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Tier</span>
|
||||||
|
<NativeSelect
|
||||||
|
:model-value="reviewForm.categoryId ?? ''"
|
||||||
|
:options="categoryOptions.map((option) => ({ label: option.label, value: option.id }))"
|
||||||
|
@update:model-value="reviewForm.categoryId = Number($event) || null"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||||
|
<NativeSelect
|
||||||
|
:model-value="selectedPlatformValue(reviewForm.platform)"
|
||||||
|
:options="[
|
||||||
|
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
|
||||||
|
{ label: 'Eigene Plattform', value: 'custom' },
|
||||||
|
]"
|
||||||
|
@update:model-value="emit('platform-change', String($event))"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
||||||
|
<input
|
||||||
|
v-model="reviewForm.platform"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="z. B. Cake, Booth, neue Plattform"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="!nomination.suggestedCategoryId"
|
||||||
|
class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
||||||
|
>
|
||||||
|
Kein automatischer Tier-Vorschlag vorhanden. Bitte manuell anhand Viewer-Range und Kanal einordnen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in diesem Tier bereits vorhanden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
<span v-if="selectedNominationHasBlockingFlag && nomination.trackingReviewStatus !== 'overridden'">
|
||||||
|
Mindestens ein Tracking-Flag blockiert die Freigabe. Bitte zuerst einen Override setzen.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Anzeigename, Handle, Plattform und Tier sind Pflicht.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-[24px] border border-violet-100 bg-white p-4 sm:p-5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<WandSparkles class="h-4 w-4 text-violet-700" />
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Moderationsnotiz & Entscheidung</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mt-4 block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
|
||||||
|
<textarea
|
||||||
|
v-model="reviewForm.reviewNote"
|
||||||
|
rows="5"
|
||||||
|
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Warum wurde übernommen oder verworfen?"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<Button :disabled="reviewSaving === nomination.id" variant="secondary" class="justify-center" @click="emit('reject', nomination.id)">
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" class="justify-center" @click="emit('approve', nomination.id)">
|
||||||
|
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||||
|
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
tabs: Array<{ id: number | null; label: string; count: number; note?: string }>
|
||||||
|
modelValue: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: number | null]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-[24px] border border-violet-100 bg-[linear-gradient(180deg,#fcfaff_0%,#f8f4ff_100%)] p-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">Unterkategorien</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Nur die aktuell gewählte Tier-Stufe wird in der Queue gezeigt.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id ?? 'all'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-[20px] border px-4 py-3 text-left transition"
|
||||||
|
:class="modelValue === tab.id ? 'border-violet-300 bg-white text-violet-900 shadow-[0_12px_28px_rgba(168,145,214,0.14)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/75 text-slate-600 hover:border-violet-200 hover:bg-white'"
|
||||||
|
@click="emit('update:modelValue', tab.id)"
|
||||||
|
>
|
||||||
|
<span class="flex items-start justify-between gap-3">
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block text-sm font-semibold">{{ tab.label }}</span>
|
||||||
|
<span v-if="tab.note" class="mt-1 block text-[11px] font-medium text-slate-500">{{ tab.note }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex min-h-8 min-w-8 items-center justify-center rounded-full border px-2 text-xs font-bold"
|
||||||
|
:class="modelValue === tab.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-violet-50 text-violet-700'"
|
||||||
|
>
|
||||||
|
{{ tab.count }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AdminNominationReviewModal from './AdminNominationReviewModal.vue'
|
||||||
|
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
|
||||||
|
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
|
||||||
|
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
|
||||||
|
import AdminReviewSubcategoryTabs from './AdminReviewSubcategoryTabs.vue'
|
||||||
|
import { getTrackerStatusMeta, useAdminReviewsManager } from './useAdminReviewsManager'
|
||||||
|
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
categoryIdsFilter?: number[] | null
|
||||||
|
activeMainCategory?: string | null
|
||||||
|
showCategoryFilterChips?: boolean
|
||||||
|
historyCollapsible?: boolean
|
||||||
|
showBlacklistButton?: boolean
|
||||||
|
}>(), {
|
||||||
|
categoryIdsFilter: null,
|
||||||
|
activeMainCategory: null,
|
||||||
|
showCategoryFilterChips: true,
|
||||||
|
historyCollapsible: false,
|
||||||
|
showBlacklistButton: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open-blacklist': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
reviewSaving,
|
||||||
|
blacklistSaving,
|
||||||
|
adminMessage,
|
||||||
|
adminError,
|
||||||
|
reviewForms,
|
||||||
|
seasonDetail,
|
||||||
|
reviewFilter,
|
||||||
|
categoryFilter,
|
||||||
|
subcategoryFilter,
|
||||||
|
selectedNominationId,
|
||||||
|
isReviewModalOpen,
|
||||||
|
candidatePlatformOptions,
|
||||||
|
filteredNominations,
|
||||||
|
selectedNomination,
|
||||||
|
reviewedNominations,
|
||||||
|
categoryOptions,
|
||||||
|
availableSubcategoryOptions,
|
||||||
|
tierCategoryOptions,
|
||||||
|
selectedCandidateCollision,
|
||||||
|
selectedRelatedPendingNominations,
|
||||||
|
selectedNominationSignalSummary,
|
||||||
|
trackingReviewNotes,
|
||||||
|
selectedNominationHasBlockingFlag,
|
||||||
|
canApproveSelected,
|
||||||
|
approveNomination,
|
||||||
|
rejectNomination,
|
||||||
|
reopenRejectedNomination,
|
||||||
|
updateTrackingReview,
|
||||||
|
addStreamUrlToBlacklist,
|
||||||
|
openNominationById,
|
||||||
|
closeNominationModal,
|
||||||
|
selectedPlatformValue,
|
||||||
|
handlePlatformSelection,
|
||||||
|
extractNominationStreamUrl,
|
||||||
|
} = useAdminReviewsManager({
|
||||||
|
categoryIdsFilter: () => props.categoryIdsFilter,
|
||||||
|
})
|
||||||
|
|
||||||
|
watchAdminToast(adminMessage, adminError)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-0 overflow-y-auto bg-white">
|
||||||
|
<AdminReviewsQueueHeader
|
||||||
|
v-model:review-filter="reviewFilter"
|
||||||
|
v-model:category-filter="categoryFilter"
|
||||||
|
:total-pending="seasonDetail.pendingNominations.length"
|
||||||
|
:visible-count="filteredNominations.length"
|
||||||
|
:category-options="categoryOptions"
|
||||||
|
:active-main-category="props.activeMainCategory"
|
||||||
|
:show-category-filter-chips="showCategoryFilterChips"
|
||||||
|
:show-blacklist-button="showBlacklistButton"
|
||||||
|
@open-blacklist="emit('open-blacklist')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="space-y-4 p-4 sm:p-6">
|
||||||
|
<AdminReviewSubcategoryTabs
|
||||||
|
v-model="subcategoryFilter"
|
||||||
|
:tabs="availableSubcategoryOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminReviewsQueueList
|
||||||
|
:nominations="filteredNominations"
|
||||||
|
:total-pending="seasonDetail.pendingNominations.length"
|
||||||
|
:selected-nomination-id="selectedNominationId"
|
||||||
|
:tracker-status-meta="getTrackerStatusMeta"
|
||||||
|
@select="selectedNominationId = $event"
|
||||||
|
@open="openNominationById"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<details v-if="historyCollapsible" class="rounded-[24px] border border-violet-100 bg-violet-50/40">
|
||||||
|
<summary class="cursor-pointer px-5 py-4 text-sm font-semibold text-violet-900">
|
||||||
|
Letzte Entscheidungen anzeigen
|
||||||
|
</summary>
|
||||||
|
<div class="border-t border-violet-100 p-5">
|
||||||
|
<AdminReviewsHistorySection
|
||||||
|
:reviewed-nominations="reviewedNominations"
|
||||||
|
:reviewed-total="seasonDetail.reviewedNominations.length"
|
||||||
|
:review-saving="reviewSaving"
|
||||||
|
@undo-rejected="reopenRejectedNomination"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<AdminReviewsHistorySection
|
||||||
|
v-else
|
||||||
|
:reviewed-nominations="reviewedNominations"
|
||||||
|
:reviewed-total="seasonDetail.reviewedNominations.length"
|
||||||
|
:review-saving="reviewSaving"
|
||||||
|
@undo-rejected="reopenRejectedNomination"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminNominationReviewModal
|
||||||
|
:open="isReviewModalOpen"
|
||||||
|
:nomination="selectedNomination"
|
||||||
|
:review-saving="reviewSaving"
|
||||||
|
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
|
||||||
|
:candidate-platform-options="candidatePlatformOptions"
|
||||||
|
:category-options="tierCategoryOptions"
|
||||||
|
:selected-candidate-collision="selectedCandidateCollision"
|
||||||
|
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||||
|
:signal-summary="selectedNominationSignalSummary"
|
||||||
|
:tracking-review-notes="trackingReviewNotes"
|
||||||
|
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
|
||||||
|
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
||||||
|
:can-approve-selected="canApproveSelected"
|
||||||
|
:blacklist-saving="blacklistSaving"
|
||||||
|
:selected-platform-value="selectedPlatformValue"
|
||||||
|
:tracker-status-meta="getTrackerStatusMeta"
|
||||||
|
@close="closeNominationModal"
|
||||||
|
@platform-change="handlePlatformSelection"
|
||||||
|
@approve="approveNomination"
|
||||||
|
@reject="rejectNomination"
|
||||||
|
@review-flagged="updateTrackingReview($event, 'reviewed')"
|
||||||
|
@override-flagged="updateTrackingReview($event, 'overridden')"
|
||||||
|
@blacklist-link="addStreamUrlToBlacklist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { RotateCcw } from '@lucide/vue'
|
||||||
|
|
||||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
reviewedNominations: AdminNominationReviewItem[]
|
reviewedNominations: AdminNominationReviewItem[]
|
||||||
reviewedTotal: number
|
reviewedTotal: number
|
||||||
|
reviewSaving?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'undo-rejected': [nominationId: number]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -28,9 +35,21 @@ defineProps<{
|
|||||||
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
|
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="nomination.status === 'approved' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'">
|
<div class="flex items-center gap-2">
|
||||||
{{ nomination.status === 'approved' ? 'übernommen' : 'verworfen' }}
|
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="nomination.status === 'approved' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'">
|
||||||
</span>
|
{{ nomination.status === 'approved' ? 'übernommen' : 'verworfen' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="nomination.status === 'rejected'"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-9 items-center justify-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-3 text-xs font-semibold text-violet-700 transition hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="reviewSaving === nomination.id"
|
||||||
|
@click="emit('undo-rejected', nomination.id)"
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-3.5 w-3.5" />
|
||||||
|
{{ reviewSaving === nomination.id ? 'Öffnet ...' : 'Undo' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="nomination.reviewNote" class="mt-3 text-sm leading-6 text-slate-600">{{ nomination.reviewNote }}</p>
|
<p v-if="nomination.reviewNote" class="mt-3 text-sm leading-6 text-slate-600">{{ nomination.reviewNote }}</p>
|
||||||
<p class="mt-2 text-xs text-slate-500">
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
|||||||
@@ -1,40 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Search } from '@lucide/vue'
|
import { Ban, Search } from '@lucide/vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
reviewFilter: string
|
reviewFilter: string
|
||||||
categoryFilter: number | null
|
categoryFilter: string | null
|
||||||
totalPending: number
|
totalPending: number
|
||||||
visibleCount: number
|
visibleCount: number
|
||||||
reviewStats: Array<{ label: string; value: number }>
|
categoryOptions: Array<{ id: string; label: string; count: number }>
|
||||||
categoryOptions: Array<{ id: number; label: string; count: number }>
|
activeMainCategory?: string | null
|
||||||
|
showCategoryFilterChips?: boolean
|
||||||
|
showBlacklistButton?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:reviewFilter': [value: string]
|
'update:reviewFilter': [value: string]
|
||||||
'update:categoryFilter': [value: number | null]
|
'update:categoryFilter': [value: string | null]
|
||||||
|
'open-blacklist': []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="border-b border-violet-100 bg-white/75 p-6">
|
<div class="border-b border-violet-100 bg-white/75 p-6">
|
||||||
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
<div class="space-y-5">
|
||||||
<div>
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
|
<div>
|
||||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Offene Nominierungen</h2>
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
|
||||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
<h2 class="mt-1 text-2xl font-bold text-slate-900">{{ activeMainCategory || 'Offene Nominierungen' }}</h2>
|
||||||
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
|
<p class="mt-1 text-sm text-slate-500">Offene Nominierungen</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
|
|
||||||
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
|
||||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
|
|
||||||
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
<button
|
||||||
|
v-if="showBlacklistButton"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
|
||||||
|
@click="emit('open-blacklist')"
|
||||||
|
>
|
||||||
|
<Ban class="h-4 w-4" />
|
||||||
|
Blacklist verwalten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="relative block">
|
<label class="relative block">
|
||||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
<input
|
<input
|
||||||
@@ -45,11 +50,9 @@ const emit = defineEmits<{
|
|||||||
@input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)"
|
@input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
|
||||||
{{ visibleCount }} / {{ totalPending }} sichtbar
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
|
<div v-if="showCategoryFilterChips !== false" class="mt-4 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
|||||||
@@ -1,42 +1,129 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
import type { AdminNominationReviewGroup } from '../../types/awards'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
nominations: AdminNominationReviewItem[]
|
nominations: AdminNominationReviewGroup[]
|
||||||
totalPending: number
|
totalPending: number
|
||||||
selectedNominationId: number | null
|
selectedNominationId: number | null
|
||||||
|
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [nominationId: number]
|
select: [nominationId: number]
|
||||||
|
open: [nominationId: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function avgViewerLabel(nomination: AdminNominationReviewGroup) {
|
||||||
|
if (nomination.avgViewers === null) return null
|
||||||
|
const avgViewerMetric = nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')
|
||||||
|
const windowLabel = avgViewerMetric?.windowLabel?.trim()
|
||||||
|
return windowLabel
|
||||||
|
? `${nomination.avgViewers} Avg Viewer · ${windowLabel}`
|
||||||
|
: `${nomination.avgViewers} Avg Viewer`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
<div class="space-y-3 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||||
|
<div v-if="nominations.length > 0" class="rounded-[22px] border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold text-slate-600">
|
||||||
|
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-500">Queue-Navigation</span>
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs font-semibold text-slate-600">
|
||||||
|
<span class="rounded-full border border-violet-100 bg-white px-3 py-1.5">
|
||||||
|
<kbd class="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">↑</kbd>
|
||||||
|
<kbd class="ml-1 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">↓</kbd>
|
||||||
|
<span class="ml-2">oder</span>
|
||||||
|
<kbd class="ml-2 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">J</kbd>
|
||||||
|
<kbd class="ml-1 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">K</kbd>
|
||||||
|
<span class="ml-2">Navigieren</span>
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-sky-100 bg-sky-50 px-3 py-1.5 text-sky-700">
|
||||||
|
<kbd class="rounded border border-sky-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">Enter</kbd>
|
||||||
|
<span class="ml-2">Modal öffnen</span>
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1.5 text-emerald-700">
|
||||||
|
<kbd class="rounded border border-emerald-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">A</kbd>
|
||||||
|
<span class="ml-2">Annehmen</span>
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-rose-700">
|
||||||
|
<kbd class="rounded border border-rose-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">R</kbd>
|
||||||
|
<span class="ml-2">Verwerfen</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="nomination in nominations"
|
v-for="nomination in nominations"
|
||||||
:key="nomination.id"
|
:key="nomination.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full rounded-2xl border p-3 text-left transition"
|
class="w-full rounded-[24px] border p-4 text-left transition"
|
||||||
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
:class="selectedNominationId === nomination.id ? 'border-violet-300 bg-white shadow-[0_18px_34px_rgba(168,145,214,0.14)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||||
@click="emit('select', nomination.id)"
|
@click="emit('select', nomination.id)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
|
<span
|
||||||
{{ nomination.categoryName }}
|
class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
:class="selectedNominationId === nomination.id ? 'bg-violet-100 text-violet-800' : 'bg-violet-50 text-violet-700'"
|
||||||
|
>
|
||||||
|
{{ nomination.categoryGroupName }}
|
||||||
</span>
|
</span>
|
||||||
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
|
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
|
||||||
ID {{ nomination.id }}
|
{{ nomination.uniqueSubmitterCount }} Nominierende
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
:class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
|
||||||
|
>
|
||||||
|
{{ props.trackerStatusMeta(nomination.trackerStatus).label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
<h3 class="mt-2 line-clamp-2 text-base font-semibold text-slate-900">{{ nomination.displayName || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||||
<p class="mt-1 truncate text-sm text-slate-500">
|
<div class="mt-2 flex flex-wrap gap-2 text-xs font-semibold text-slate-500">
|
||||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
<span class="rounded-full border border-violet-100 bg-violet-50/50 px-2.5 py-1">{{ nomination.suggestedCategoryName || 'Tier offen' }}</span>
|
||||||
|
<span class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">{{ nomination.nominationTally }} Nominierungen</span>
|
||||||
|
<span class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende</span>
|
||||||
|
<span v-if="avgViewerLabel(nomination)" class="rounded-full border border-sky-100 bg-sky-50 px-2.5 py-1 text-sky-700">{{ avgViewerLabel(nomination) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="nomination.trackingFlags.some((flag) => flag.blocksApproval)"
|
||||||
|
class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-rose-700"
|
||||||
|
>
|
||||||
|
Blockierende Flags
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="nomination.requiresManualReview"
|
||||||
|
class="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
|
||||||
|
>
|
||||||
|
Manuelle Pruefung
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="!nomination.suggestedCategoryName"
|
||||||
|
class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-600"
|
||||||
|
>
|
||||||
|
Tier manuell waehlen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs font-medium text-slate-400">
|
||||||
|
zuletzt {{ new Date(nomination.lastSubmittedAt).toLocaleString('de-DE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
<div
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-bold"
|
||||||
|
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-500'"
|
||||||
|
>
|
||||||
|
#{{ nomination.id }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-violet-200 bg-white px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
|
||||||
|
@click.stop="emit('open', nomination.id)"
|
||||||
|
>
|
||||||
|
Prüfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Clock3 } from '@lucide/vue'
|
||||||
|
|
||||||
|
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
form: AdminOperationalSettingsForm
|
||||||
|
saving: boolean
|
||||||
|
canManage: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<Clock3 class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Session Timeout</p>
|
||||||
|
<h3 class="mt-1 text-xl font-bold text-slate-900">Automatischer Logout bei Inaktivitaet</h3>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||||
|
Team- und Admin-Sessions werden nach der eingestellten Zeit ohne Aktivitaet automatisch beendet. Minimum sind 3 Stunden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-[minmax(0,220px)_1fr] md:items-start">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Inaktiv nach Stunden</span>
|
||||||
|
<input
|
||||||
|
v-model.number="form.sessionIdleTimeoutHours"
|
||||||
|
:disabled="saving || !canManage"
|
||||||
|
type="number"
|
||||||
|
min="3"
|
||||||
|
step="1"
|
||||||
|
inputmode="numeric"
|
||||||
|
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
||||||
|
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user