Improve admin candidate modal UX and add clip menu visibility toggle

- Widen AdminCandidateEditorModal to size lg for better readability
- Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link"
- Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport
- Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-27 18:39:35 +02:00
parent 494eba5edd
commit 18b61bed52
119 changed files with 15638 additions and 367 deletions
+1
View File
@@ -184,5 +184,6 @@ public static class SeasonMappings
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
new FooterLinkDto("sponsors", "Sponsoren & Partner", settings.SponsorsUrl, settings.SponsorsContent),
new FooterLinkDto("showacts", "Showacts", settings.ShowactsUrl, settings.ShowactsContent),
];
}
@@ -91,6 +91,14 @@ public sealed record ApproveNominationRequest(
public sealed record RejectNominationRequest(string? ReviewNote);
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
public sealed record UpdateNominationLinkBlacklistRequest(string[] Urls);
public sealed record AddNominationLinkBlacklistEntryRequest(string Url);
public sealed record UpdateClipStatusRequest(
string Status,
string? ReviewNote);
@@ -116,3 +124,15 @@ public sealed record AdminRiskRuleDto(
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
public sealed record UpdateRiskRulesRequest(AdminRiskRuleDto[] Rules);
public sealed record AdminWorkflowRuleDto(
string Key,
string Label,
bool Enabled,
int Limit,
string Mode,
string Description);
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
+14 -2
View File
@@ -23,7 +23,13 @@ public sealed record AdminCandidateItemDto(
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
string Platform,
string AcceptanceStatus,
string? AcceptanceNote,
string? ClipCompilationUrl,
string? ClipCompilationTitle,
string? ClipCompilationPlatform,
string ClipEmbedStatus);
public sealed record AdminAwardResultItemDto(
int Id,
@@ -102,7 +108,13 @@ public sealed record UpsertCandidateRequest(
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
string Platform,
string? AcceptanceStatus = null,
string? AcceptanceNote = null,
string? ClipCompilationUrl = null,
string? ClipCompilationTitle = null,
string? ClipCompilationPlatform = null,
string? ClipEmbedStatus = null);
public sealed record SetAwardResultRequest(
int CategoryId,
@@ -14,6 +14,8 @@ public sealed record AdminSiteSettingsResponse(
string ContactContent,
string SponsorsUrl,
string SponsorsContent,
string ShowactsUrl,
string ShowactsContent,
IEnumerable<PublicSocialLinkDto> SocialLinks,
IEnumerable<FaqItemDto> Faq);
@@ -29,6 +31,8 @@ public sealed record UpdateSiteSettingsRequest(
string ContactContent,
string SponsorsUrl,
string SponsorsContent,
string ShowactsUrl,
string ShowactsContent,
PublicSocialLinkDto[] SocialLinks,
FaqItemDto[] Faq);
@@ -49,6 +53,24 @@ public sealed record AdminOperationalSettingsResponse(
string MaintenanceTitle,
string MaintenanceMessage);
public sealed record AdminOptionalFeatureSettingsResponse(
bool ClipSubmissionsEnabled,
bool ClipReviewEnabled,
bool ClipAdminMenuVisible,
string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled,
string ShowactApplicationDisabledMessage,
bool SponsorsVisible);
public sealed record UpdateOptionalFeatureSettingsRequest(
bool ClipSubmissionsEnabled,
bool ClipReviewEnabled,
bool ClipAdminMenuVisible,
string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled,
string ShowactApplicationDisabledMessage,
bool SponsorsVisible);
public sealed record UpdateOperationalSettingsRequest(
bool DemoLoginEnabled,
string DemoLoginEmail,
+51
View File
@@ -0,0 +1,51 @@
namespace Backend.Contracts;
public sealed record SponsorDto(
int Id,
int SeasonId,
string Name,
string WebsiteUrl,
string LogoUrl,
string Description,
string Tier,
int SortOrder,
bool IsVisible);
public sealed record PublicSponsorsResponse(int Year, SponsorDto[] Items);
public sealed record UpsertSponsorRequest(
string Name,
string WebsiteUrl,
string LogoUrl,
string Description,
string Tier,
int SortOrder,
bool IsVisible);
public sealed record ShowactApplicationDto(
int Id,
int SeasonId,
string ArtistName,
string ContactEmail,
string ContactDiscord,
string PlatformUrl,
string PerformanceType,
string Description,
string TechnicalNotes,
string ReferenceUrl,
string Status,
string? ReviewNote,
DateTimeOffset CreatedAt,
DateTimeOffset? ReviewedAt);
public sealed record CreateShowactApplicationRequest(
string ArtistName,
string ContactEmail,
string ContactDiscord,
string PlatformUrl,
string PerformanceType,
string Description,
string TechnicalNotes,
string ReferenceUrl);
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
+14 -1
View File
@@ -20,7 +20,11 @@ public sealed record WinnerPreviewDto(
string WinnerName,
string WinnerSlug,
string WinnerPlatform,
string WinnerUrl);
string WinnerUrl,
string? ClipUrl,
string? ClipTitle,
string? ClipPlatform,
string? ClipEmbedStatus);
public sealed record ArchiveYearDto(
int Year,
@@ -57,6 +61,14 @@ public sealed record PublicSiteStatusResponse(
string MaintenanceTitle,
string MaintenanceMessage);
public sealed record PublicFeatureFlagsDto(
bool ClipSubmissionsEnabled,
bool ClipReviewEnabled,
string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled,
string ShowactApplicationDisabledMessage,
bool SponsorsVisible);
public sealed record OverviewResponse(
int SeasonId,
int Year,
@@ -72,4 +84,5 @@ public sealed record OverviewResponse(
IEnumerable<WinnerPreviewDto> WinnersPreview,
IEnumerable<ArchiveYearDto> ArchiveYears,
PublicSiteContentDto SiteContent,
PublicFeatureFlagsDto FeatureFlags,
IEnumerable<FaqItemDto> Faq);
@@ -4,10 +4,12 @@ public sealed record CandidateSummaryDto(
int Id,
string DisplayName,
string ChannelSlug,
string ChannelUrl,
string Platform,
string? ClipUrl,
string? ClipTitle,
string? ClipPlatform);
string? ClipPlatform,
string? ClipEmbedStatus);
public sealed record PublicCategoryDetailDto(
int Id,
@@ -5,7 +5,11 @@ public sealed record WinnerArchiveItemDto(
string WinnerName,
string WinnerSlug,
string WinnerPlatform,
string WinnerUrl);
string WinnerUrl,
string? ClipUrl,
string? ClipTitle,
string? ClipPlatform,
string? ClipEmbedStatus);
public sealed record WinnerArchiveResponse(
int Year,
+46
View File
@@ -16,6 +16,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
public DbSet<ShowactApplication> ShowactApplications => Set<ShowactApplication>();
public DbSet<Sponsor> Sponsors => Set<Sponsor>();
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
@@ -40,6 +42,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.ImprintUrl).HasMaxLength(400);
entity.Property(item => item.ContactUrl).HasMaxLength(400);
entity.Property(item => item.SponsorsUrl).HasMaxLength(400);
entity.Property(item => item.ShowactsUrl).HasMaxLength(400);
entity.Property(item => item.ShowactsContent).HasDefaultValue(string.Empty);
entity.Property(item => item.DemoLoginEmail).HasMaxLength(180);
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
@@ -51,6 +55,14 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.TwitchScope).HasMaxLength(300);
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
});
modelBuilder.Entity<TeamMember>(entity =>
@@ -88,6 +100,12 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
entity.Property(item => item.ClipCompilationTitle).HasMaxLength(200);
entity.Property(item => item.ClipCompilationPlatform).HasMaxLength(40);
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
});
modelBuilder.Entity<Nomination>(entity =>
@@ -169,6 +187,34 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<ShowactApplication>(entity =>
{
entity.Property(item => item.ArtistName).HasMaxLength(120);
entity.Property(item => item.ContactEmail).HasMaxLength(180);
entity.Property(item => item.ContactDiscord).HasMaxLength(120);
entity.Property(item => item.PlatformUrl).HasMaxLength(500);
entity.Property(item => item.PerformanceType).HasMaxLength(80);
entity.Property(item => item.Description).HasMaxLength(1000);
entity.Property(item => item.TechnicalNotes).HasMaxLength(1000);
entity.Property(item => item.ReferenceUrl).HasMaxLength(500);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400);
entity.HasIndex(item => new { item.SeasonId, item.Status });
});
modelBuilder.Entity<Sponsor>(entity =>
{
entity.Property(item => item.Name).HasMaxLength(120);
entity.Property(item => item.WebsiteUrl).HasMaxLength(500);
entity.Property(item => item.LogoUrl).HasMaxLength(500);
entity.Property(item => item.Description).HasMaxLength(500);
entity.Property(item => item.Tier).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.IsVisible, item.SortOrder });
});
SeedData.Apply(modelBuilder);
}
}
@@ -16,6 +16,30 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
@@ -40,6 +64,12 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactsUrl" character varying(400) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL,
@@ -121,6 +151,74 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
ON "ClipSubmissions" ("CandidateId");
CREATE TABLE IF NOT EXISTS "ShowactApplications" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"ArtistName" character varying(120) NOT NULL,
"ContactEmail" character varying(180) NOT NULL,
"ContactDiscord" character varying(120) NOT NULL,
"PlatformUrl" character varying(500) NOT NULL,
"PerformanceType" character varying(80) NOT NULL,
"Description" character varying(1000) NOT NULL,
"TechnicalNotes" character varying(1000) NOT NULL,
"ReferenceUrl" character varying(500) NOT NULL,
"Status" character varying(20) NOT NULL,
"ReviewNote" character varying(500) NULL,
"ReviewedByTwitchId" character varying(120) NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"UserAgent" character varying(400) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone NULL
);
CREATE INDEX IF NOT EXISTS "IX_ShowactApplications_SeasonId_Status"
ON "ShowactApplications" ("SeasonId", "Status");
CREATE TABLE IF NOT EXISTS "Sponsors" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"Name" character varying(120) NOT NULL,
"WebsiteUrl" character varying(500) NOT NULL,
"LogoUrl" character varying(500) NOT NULL,
"Description" character varying(500) NOT NULL,
"Tier" character varying(80) NOT NULL,
"SortOrder" integer NOT NULL,
"IsVisible" boolean NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone NULL
);
CREATE INDEX IF NOT EXISTS "IX_Sponsors_SeasonId_IsVisible_SortOrder"
ON "Sponsors" ("SeasonId", "IsVisible", "SortOrder");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ShowactApplications_Seasons_SeasonId'
) THEN
ALTER TABLE "ShowactApplications"
ADD CONSTRAINT "FK_ShowactApplications_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
ON DELETE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_Sponsors_Seasons_SeasonId'
) THEN
ALTER TABLE "Sponsors"
ADD CONSTRAINT "FK_Sponsors_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
ON DELETE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -138,6 +236,24 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "AcceptanceStatus" character varying(30) NOT NULL DEFAULT 'open';
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "AcceptanceNote" character varying(500) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationUrl" character varying(500) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationTitle" character varying(200) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationPlatform" character varying(40) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
+10
View File
@@ -48,7 +48,17 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
ContactContent = SeedCatalog.DefaultContactContent,
SponsorsUrl = "https://vtuber-star-awards.de/partner",
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults),
NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
ClipSubmissionsEnabled = false,
ClipReviewEnabled = true,
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
ShowactApplicationsEnabled = false,
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
SponsorsVisible = true,
SocialLinksJson = JsonSerializer.Serialize(new[]
{
new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true },
@@ -41,6 +41,26 @@ public static partial class SeedDataBootstrapper
settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults);
}
if (!HasValidWorkflowRules(settings.WorkflowRulesJson))
{
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults);
}
if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
{
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
}
if (string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage))
{
settings.ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
}
if (string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage))
{
settings.ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.";
}
if (string.IsNullOrWhiteSpace(settings.ImprintContent))
{
settings.ImprintContent = SeedCatalog.DefaultImprintContent;
@@ -55,6 +75,16 @@ public static partial class SeedDataBootstrapper
{
settings.SponsorsContent = SeedCatalog.DefaultSponsorsContent;
}
if (string.IsNullOrWhiteSpace(settings.ShowactsUrl))
{
settings.ShowactsUrl = "https://vtuber-star-awards.de/showacts";
}
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
{
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.";
}
}
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
@@ -87,4 +117,30 @@ public static partial class SeedDataBootstrapper
private static bool HasValidRiskRules(string? json) =>
RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length;
private static bool HasValidWorkflowRules(string? json) =>
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length;
private static bool HasValidNominationLinkBlacklist(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
try
{
using var document = JsonDocument.Parse(json);
return document.RootElement.ValueKind == JsonValueKind.Array
&& document.RootElement.EnumerateArray().Any(item =>
item.ValueKind == JsonValueKind.Object
&& item.TryGetProperty("Url", out var url)
&& url.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(url.GetString()));
}
catch (JsonException)
{
return false;
}
}
}
+6
View File
@@ -10,4 +10,10 @@ public sealed class Candidate
public string DisplayName { get; set; } = string.Empty;
public string ChannelSlug { get; set; } = string.Empty;
public string Platform { get; set; } = "Twitch";
public string AcceptanceStatus { get; set; } = "open";
public string? AcceptanceNote { get; set; }
public string? ClipCompilationUrl { get; set; }
public string? ClipCompilationTitle { get; set; }
public string? ClipCompilationPlatform { get; set; }
public string ClipEmbedStatus { get; set; } = "unchecked";
}
+23
View File
@@ -0,0 +1,23 @@
namespace Backend.Domain;
public sealed class ShowactApplication
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public string ArtistName { get; set; } = string.Empty;
public string ContactEmail { get; set; } = string.Empty;
public string ContactDiscord { get; set; } = string.Empty;
public string PlatformUrl { get; set; } = string.Empty;
public string PerformanceType { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string TechnicalNotes { get; set; } = string.Empty;
public string ReferenceUrl { get; set; } = string.Empty;
public string Status { get; set; } = "pending";
public string? ReviewNote { get; set; }
public string? ReviewedByTwitchId { get; set; }
public string CreatedFromIp { get; set; } = string.Empty;
public string UserAgent { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ReviewedAt { get; set; }
}
+11
View File
@@ -16,9 +16,20 @@ public sealed class SiteSettings
public string ContactContent { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
public string SponsorsContent { get; set; } = string.Empty;
public string ShowactsUrl { get; set; } = string.Empty;
public string ShowactsContent { get; set; } = string.Empty;
public string SocialLinksJson { get; set; } = "[]";
public string FaqJson { get; set; } = "[]";
public string RiskRulesJson { get; set; } = "[]";
public string WorkflowRulesJson { get; set; } = "[]";
public string NominationLinkBlacklistJson { get; set; } = "[]";
public bool ClipSubmissionsEnabled { get; set; }
public bool ClipReviewEnabled { get; set; } = true;
public bool ClipAdminMenuVisible { get; set; } = true;
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
public bool ShowactApplicationsEnabled { get; set; }
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
public bool SponsorsVisible { get; set; } = true;
public bool DemoLoginManagedByDatabase { get; set; }
public bool DemoLoginEnabled { get; set; }
public string DemoLoginEmail { get; set; } = string.Empty;
+17
View File
@@ -0,0 +1,17 @@
namespace Backend.Domain;
public sealed class Sponsor
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public string Name { get; set; } = string.Empty;
public string WebsiteUrl { get; set; } = string.Empty;
public string LogoUrl { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Tier { get; set; } = "Partner";
public int SortOrder { get; set; }
public bool IsVisible { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? UpdatedAt { get; set; }
}
+1
View File
@@ -14,6 +14,7 @@ public static class AdminEndpoints
group.MapAdminDashboardEndpoints();
group.MapAdminSeasonManagementEndpoints();
group.MapAdminModerationEndpoints();
group.MapAdminExtrasEndpoints();
group.MapAdminTeamEndpoints();
return app;
+321
View File
@@ -0,0 +1,321 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminExtrasEndpoints
{
private static readonly string[] ContentPermissions = ["content", "settings"];
private static readonly HashSet<string> AllowedShowactStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"pending",
"shortlisted",
"accepted",
"rejected",
};
public static RouteGroupBuilder MapAdminExtrasEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/seasons/{seasonId:int}/showacts", GetShowactApplications)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
.WithName("GetAdminShowactApplications")
.WithOpenApi();
group.MapPost("/showacts/{applicationId:int}/status", UpdateShowactStatus)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("UpdateAdminShowactStatus")
.WithOpenApi();
group.MapDelete("/showacts/{applicationId:int}", DeleteShowactApplication)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("DeleteAdminShowactApplication")
.WithOpenApi();
group.MapGet("/seasons/{seasonId:int}/sponsors", GetSponsors)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
.WithName("GetAdminSponsors")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/sponsors", CreateSponsor)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("CreateAdminSponsor")
.WithOpenApi();
group.MapPut("/sponsors/{sponsorId:int}", UpdateSponsor)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("UpdateAdminSponsor")
.WithOpenApi();
group.MapDelete("/sponsors/{sponsorId:int}", DeleteSponsor)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("DeleteAdminSponsor")
.WithOpenApi();
return group;
}
private static async Task<IResult> GetShowactApplications(int seasonId, AwardsDbContext db)
{
var applications = await db.ShowactApplications
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.Status == "pending" ? 0 : 1)
.ThenByDescending(item => item.CreatedAt)
.Select(item => ToDto(item))
.ToArrayAsync();
return Results.Ok(applications);
}
private static async Task<IResult> UpdateShowactStatus(
HttpContext context,
int applicationId,
UpdateShowactStatusRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
if (application is null)
{
return Results.NotFound();
}
var status = NormalizeStatus(request.Status);
if (!AllowedShowactStatuses.Contains(status))
{
return Results.BadRequest(new { message = "Status muss pending, shortlisted, accepted oder rejected sein." });
}
var session = AdminEndpointConventions.CurrentSession(context);
var before = new { application.Status, application.ReviewNote };
application.Status = status;
application.ReviewNote = NormalizeText(request.ReviewNote, 500);
application.ReviewedByTwitchId = session.TwitchUserId;
application.ReviewedAt = DateTimeOffset.UtcNow;
adminAuditService.AddEntry(
session.TwitchUserId,
"showact.status",
"showact-application",
application.Id.ToString(),
$"Showact-Bewerbung von {application.ArtistName} wurde auf {status} gesetzt.",
new { before, after = new { application.Status, application.ReviewNote } },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, application = ToDto(application) });
}
private static async Task<IResult> DeleteShowactApplication(
HttpContext context,
int applicationId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
if (application is null)
{
return Results.NotFound();
}
var session = AdminEndpointConventions.CurrentSession(context);
db.ShowactApplications.Remove(application);
adminAuditService.AddEntry(
session.TwitchUserId,
"showact.delete",
"showact-application",
application.Id.ToString(),
$"Showact-Bewerbung von {application.ArtistName} wurde geloescht.",
new { application.ArtistName, application.ContactEmail, application.Status },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, applicationId });
}
private static async Task<IResult> GetSponsors(int seasonId, AwardsDbContext db)
{
var sponsors = await db.Sponsors
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(item => ToDto(item))
.ToArrayAsync();
return Results.Ok(sponsors);
}
private static async Task<IResult> CreateSponsor(
HttpContext context,
int seasonId,
UpsertSponsorRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
if (!await db.Seasons.AnyAsync(item => item.Id == seasonId, context.RequestAborted))
{
return Results.NotFound();
}
var sponsor = new Sponsor { SeasonId = seasonId, CreatedAt = DateTimeOffset.UtcNow };
var validation = ApplySponsorRequest(sponsor, request);
if (validation is not null)
{
return validation;
}
db.Sponsors.Add(sponsor);
var session = AdminEndpointConventions.CurrentSession(context);
adminAuditService.AddEntry(
session.TwitchUserId,
"sponsor.create",
"sponsor",
"new",
$"Sponsor {sponsor.Name} wurde angelegt.",
ToDto(sponsor),
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
}
private static async Task<IResult> UpdateSponsor(
HttpContext context,
int sponsorId,
UpsertSponsorRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
if (sponsor is null)
{
return Results.NotFound();
}
var before = ToDto(sponsor);
var validation = ApplySponsorRequest(sponsor, request);
if (validation is not null)
{
return validation;
}
sponsor.UpdatedAt = DateTimeOffset.UtcNow;
var session = AdminEndpointConventions.CurrentSession(context);
adminAuditService.AddEntry(
session.TwitchUserId,
"sponsor.update",
"sponsor",
sponsor.Id.ToString(),
$"Sponsor {sponsor.Name} wurde aktualisiert.",
new { before, after = ToDto(sponsor) },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
}
private static async Task<IResult> DeleteSponsor(
HttpContext context,
int sponsorId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
if (sponsor is null)
{
return Results.NotFound();
}
var session = AdminEndpointConventions.CurrentSession(context);
db.Sponsors.Remove(sponsor);
adminAuditService.AddEntry(
session.TwitchUserId,
"sponsor.delete",
"sponsor",
sponsor.Id.ToString(),
$"Sponsor {sponsor.Name} wurde geloescht.",
ToDto(sponsor),
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, sponsorId });
}
private static IResult? ApplySponsorRequest(Sponsor sponsor, UpsertSponsorRequest request)
{
var name = NormalizeText(request.Name, 120);
if (string.IsNullOrWhiteSpace(name))
{
return Results.BadRequest(new { message = "Sponsor-Name ist erforderlich." });
}
var websiteUrl = NormalizeText(request.WebsiteUrl, 500);
var logoUrl = NormalizeText(request.LogoUrl, 500);
if (!IsBlankOrHttpUrl(websiteUrl) || !IsBlankOrHttpUrl(logoUrl))
{
return Results.BadRequest(new { message = "Sponsor-Links muessen gueltige http(s)-URLs sein." });
}
sponsor.Name = name;
sponsor.WebsiteUrl = websiteUrl;
sponsor.LogoUrl = logoUrl;
sponsor.Description = NormalizeText(request.Description, 500);
sponsor.Tier = NormalizeText(request.Tier, 80);
if (string.IsNullOrWhiteSpace(sponsor.Tier))
{
sponsor.Tier = "Partner";
}
sponsor.SortOrder = Math.Clamp(request.SortOrder, 0, 9999);
sponsor.IsVisible = request.IsVisible;
return null;
}
private static SponsorDto ToDto(Sponsor sponsor) =>
new(
sponsor.Id,
sponsor.SeasonId,
sponsor.Name,
sponsor.WebsiteUrl,
sponsor.LogoUrl,
sponsor.Description,
sponsor.Tier,
sponsor.SortOrder,
sponsor.IsVisible);
private static ShowactApplicationDto ToDto(ShowactApplication application) =>
new(
application.Id,
application.SeasonId,
application.ArtistName,
application.ContactEmail,
application.ContactDiscord,
application.PlatformUrl,
application.PerformanceType,
application.Description,
application.TechnicalNotes,
application.ReferenceUrl,
application.Status,
application.ReviewNote,
application.CreatedAt,
application.ReviewedAt);
private static string NormalizeStatus(string? value) =>
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
private static string NormalizeText(string? value, int maxLength)
{
var trimmed = (value ?? string.Empty).Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private static bool IsBlankOrHttpUrl(string value) =>
string.IsNullOrWhiteSpace(value)
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
}
@@ -20,6 +20,18 @@ public static partial class AdminModerationEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("RejectAdminNomination")
.WithOpenApi();
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("GetAdminNominationLinkBlacklist")
.WithOpenApi();
group.MapPut("/nominations/link-blacklist", UpdateNominationLinkBlacklist)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("UpdateAdminNominationLinkBlacklist")
.WithOpenApi();
group.MapPost("/nominations/link-blacklist", AddNominationLinkBlacklistEntry)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("AddAdminNominationLinkBlacklistEntry")
.WithOpenApi();
group.MapGet("/risk-flags", GetRiskFlags)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
.WithName("GetAdminRiskFlags")
@@ -0,0 +1,123 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
private static async Task<IResult> GetNominationLinkBlacklist(AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
}
private static async Task<IResult> UpdateNominationLinkBlacklist(
HttpContext context,
UpdateNominationLinkBlacklistRequest 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 normalizedEntries = NormalizeBlacklistEntries(request.Urls, out var invalidUrl);
if (invalidUrl is not null)
{
return Results.BadRequest(new { message = $"Blacklist-Link ist keine gueltige http(s)-URL: {invalidUrl}" });
}
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(normalizedEntries);
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination-link-blacklist.update",
"site-settings",
settings.Id.ToString(),
"Nominierungs-Link-Blacklist wurde aktualisiert.",
new { count = normalizedEntries.Length },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
}
private static async Task<IResult> AddNominationLinkBlacklistEntry(
HttpContext context,
AddNominationLinkBlacklistEntryRequest 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();
}
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(request.Url, out var normalizedUrl))
{
return Results.BadRequest(new { message = "Blacklist-Link ist keine gueltige http(s)-URL." });
}
var entries = NominationLinkBlacklistSettings.Read(settings).ToList();
if (!NominationLinkBlacklistSettings.IsBlocked(normalizedUrl, entries))
{
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(entries);
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination-link-blacklist.add",
"site-settings",
settings.Id.ToString(),
"Link wurde zur Nominierungs-Blacklist hinzugefuegt.",
new { url = normalizedUrl },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
}
private static AdminNominationLinkBlacklistResponse ToNominationLinkBlacklistResponse(Backend.Domain.SiteSettings settings) =>
new(NominationLinkBlacklistSettings.Read(settings)
.Select(entry => new AdminNominationLinkBlacklistEntryDto(entry.Url))
.ToArray());
private static NominationLinkBlacklistEntry[] NormalizeBlacklistEntries(string[]? urls, out string? invalidUrl)
{
invalidUrl = null;
var entries = new List<NominationLinkBlacklistEntry>();
foreach (var rawUrl in urls ?? [])
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
continue;
}
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(rawUrl, out var normalizedUrl))
{
invalidUrl = rawUrl;
return [];
}
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
}
return entries
.DistinctBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
@@ -9,6 +9,9 @@ namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static readonly string[] CandidateAcceptanceStatuses = ["open", "contacted", "accepted", "declined"];
private static readonly string[] CandidateClipEmbedStatuses = ["unchecked", "embeddable", "link_only", "blocked"];
private static async Task<IResult> CreateCandidate(
HttpContext context,
int seasonId,
@@ -31,6 +34,20 @@ public static partial class AdminSeasonManagementEndpoints
var normalizedDisplayName = request.DisplayName.Trim();
var normalizedChannelSlug = request.ChannelSlug.Trim();
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
if (normalizedClipUrl is null)
{
normalizedClipTitle = null;
normalizedClipPlatform = null;
normalizedClipEmbedStatus = "unchecked";
}
if (await db.Candidates.AnyAsync(item =>
item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId
@@ -40,6 +57,20 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
}
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
db,
seasonId,
request.CategoryId,
null,
normalizedDisplayName,
normalizedChannelSlug,
normalizedAcceptanceStatus,
context.RequestAborted);
if (workflowRuleBlock is not null)
{
return workflowRuleBlock;
}
var candidate = new Candidate
{
SeasonId = seasonId,
@@ -47,6 +78,12 @@ public static partial class AdminSeasonManagementEndpoints
DisplayName = normalizedDisplayName,
ChannelSlug = normalizedChannelSlug,
Platform = request.Platform.Trim(),
AcceptanceStatus = normalizedAcceptanceStatus,
AcceptanceNote = normalizedAcceptanceNote,
ClipCompilationUrl = normalizedClipUrl,
ClipCompilationTitle = normalizedClipTitle,
ClipCompilationPlatform = normalizedClipPlatform,
ClipEmbedStatus = normalizedClipEmbedStatus,
};
db.Candidates.Add(candidate);
@@ -56,7 +93,7 @@ public static partial class AdminSeasonManagementEndpoints
"candidate",
request.DisplayName.Trim(),
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
new { seasonId, request.CategoryId, request.Platform },
new { seasonId, request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
@@ -92,6 +129,20 @@ public static partial class AdminSeasonManagementEndpoints
var normalizedDisplayName = request.DisplayName.Trim();
var normalizedChannelSlug = request.ChannelSlug.Trim();
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
if (normalizedClipUrl is null)
{
normalizedClipTitle = null;
normalizedClipPlatform = null;
normalizedClipEmbedStatus = "unchecked";
}
if (await db.Candidates.AnyAsync(item =>
item.SeasonId == candidate.SeasonId
&& item.CategoryId == request.CategoryId
@@ -102,10 +153,30 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
}
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
db,
candidate.SeasonId,
request.CategoryId,
candidateId,
normalizedDisplayName,
normalizedChannelSlug,
normalizedAcceptanceStatus,
context.RequestAborted);
if (workflowRuleBlock is not null)
{
return workflowRuleBlock;
}
candidate.CategoryId = request.CategoryId;
candidate.DisplayName = normalizedDisplayName;
candidate.ChannelSlug = normalizedChannelSlug;
candidate.Platform = request.Platform.Trim();
candidate.AcceptanceStatus = normalizedAcceptanceStatus;
candidate.AcceptanceNote = normalizedAcceptanceNote;
candidate.ClipCompilationUrl = normalizedClipUrl;
candidate.ClipCompilationTitle = normalizedClipTitle;
candidate.ClipCompilationPlatform = normalizedClipPlatform;
candidate.ClipEmbedStatus = normalizedClipEmbedStatus;
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -113,7 +184,7 @@ public static partial class AdminSeasonManagementEndpoints
"candidate",
candidate.Id.ToString(),
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
new { request.CategoryId, request.Platform },
new { request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
@@ -146,4 +217,34 @@ public static partial class AdminSeasonManagementEndpoints
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, candidateId });
}
private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection<string> allowedValues)
{
var normalized = value?.Trim().ToLowerInvariant();
return !string.IsNullOrWhiteSpace(normalized) && allowedValues.Contains(normalized)
? normalized
: fallback;
}
private static string? NormalizeOptionalCandidateText(string? value)
{
var normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static string? NormalizeOptionalCandidateUrl(string? value)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new BadHttpRequestException("Compilation-Link muss eine gültige http(s)-URL sein.");
}
return uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
}
}
@@ -26,7 +26,13 @@ public static partial class AdminSeasonManagementEndpoints
item.CategoryId,
item.DisplayName,
item.ChannelSlug,
item.Platform))
item.Platform,
item.AcceptanceStatus,
item.AcceptanceNote,
item.ClipCompilationUrl,
item.ClipCompilationTitle,
item.ClipCompilationPlatform,
item.ClipEmbedStatus))
.ToArrayAsync();
var candidateCounts = candidates
@@ -56,6 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("DeleteAdminResult")
.WithOpenApi();
group.MapGet("/workflow-rules", GetWorkflowRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("GetAdminWorkflowRules")
.WithOpenApi();
group.MapPut("/workflow-rules", UpdateWorkflowRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("UpdateAdminWorkflowRules")
.WithOpenApi();
return group;
}
}
@@ -1,6 +1,8 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
@@ -14,6 +16,17 @@ public static partial class AdminSeasonManagementEndpoints
private const int MaxCandidateDisplayNameLength = 120;
private const int MaxCandidateChannelSlugLength = 120;
private const int MaxCandidatePlatformLength = 60;
private const int MaxCandidateAcceptanceNoteLength = 500;
private const int MaxCandidateClipUrlLength = 500;
private const int MaxCandidateClipTitleLength = 200;
private const int MaxCandidateClipPlatformLength = 40;
private sealed record CandidateRuleSnapshot(
int Id,
int CategoryId,
string DisplayName,
string ChannelSlug,
string AcceptanceStatus);
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
{
@@ -132,6 +145,80 @@ public static partial class AdminSeasonManagementEndpoints
});
}
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
return WorkflowRuleSettings.Read(settings);
}
private static IResult CreateWorkflowRuleError(string message) =>
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
private static async Task<IResult?> BuildCandidateWorkflowRuleBlockAsync(
AwardsDbContext db,
int seasonId,
int categoryId,
int? existingCandidateId,
string displayName,
string channelSlug,
string acceptanceStatus,
CancellationToken cancellationToken)
{
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
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 CandidateRuleSnapshot(
item.Id,
item.CategoryId,
item.DisplayName,
item.ChannelSlug,
item.AcceptanceStatus))
.ToArrayAsync(cancellationToken);
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
{
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
if (categoryCount >= finalistsRule.Limit)
{
return CreateWorkflowRuleError(
$"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 =>
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
if (appearanceCount >= appearancesRule.Limit)
{
return CreateWorkflowRuleError(
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
}
}
return null;
}
private static string[] BuildNewSeasonReadinessIssues(
string currentPhase,
bool isCurrent,
@@ -311,6 +398,51 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." });
}
if (!IsAllowedCandidateChoice(request.AcceptanceStatus, CandidateAcceptanceStatuses))
{
return Results.BadRequest(new { message = "Acceptance status must be open, contacted, accepted, or declined." });
}
if (!IsAllowedCandidateChoice(request.ClipEmbedStatus, CandidateClipEmbedStatuses))
{
return Results.BadRequest(new { message = "Clip embed status must be unchecked, embeddable, link_only, or blocked." });
}
if (request.AcceptanceNote?.Trim().Length > MaxCandidateAcceptanceNoteLength)
{
return Results.BadRequest(new { message = $"Acceptance note must stay below {MaxCandidateAcceptanceNoteLength} characters." });
}
var clipUrl = request.ClipCompilationUrl?.Trim();
if (!string.IsNullOrWhiteSpace(clipUrl))
{
if (clipUrl.Length > MaxCandidateClipUrlLength)
{
return Results.BadRequest(new { message = $"Compilation link must stay below {MaxCandidateClipUrlLength} characters." });
}
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
return Results.BadRequest(new { message = "Compilation link must be a valid http(s) URL." });
}
}
if (request.ClipCompilationTitle?.Trim().Length > MaxCandidateClipTitleLength)
{
return Results.BadRequest(new { message = $"Compilation title must stay below {MaxCandidateClipTitleLength} characters." });
}
if (request.ClipCompilationPlatform?.Trim().Length > MaxCandidateClipPlatformLength)
{
return Results.BadRequest(new { message = $"Compilation platform must stay below {MaxCandidateClipPlatformLength} characters." });
}
return null;
}
private static bool IsAllowedCandidateChoice(string? value, IReadOnlyCollection<string> allowedValues)
{
var normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) || allowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase);
}
}
@@ -36,6 +36,40 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
}
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
{
return CreateWorkflowRuleError(
"Dieser Kandidat hat noch keinen gepflegten Clip-Link. Bitte zuerst die Clip-Compilation am Kandidaten hinterlegen oder die Workflow-Regel umstellen.");
}
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
{
var candidateIdentityKey = WorkflowRuleSettings.CandidateIdentityKey(candidate);
var existingWinnerIdentities = await db.Results
.AsNoTracking()
.Include(item => item.Candidate)
.Where(item => item.SeasonId == seasonId && item.CategoryId != request.CategoryId)
.Select(item => new
{
item.CategoryId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
})
.ToArrayAsync(context.RequestAborted);
var existingWinnerCount = existingWinnerIdentities.Count(item =>
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
if (existingWinnerCount >= winnerPlacementsRule.Limit)
{
return CreateWorkflowRuleError(
$"Diese Person hat bereits {existingWinnerCount} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
}
}
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId);
+118 -1
View File
@@ -13,6 +13,7 @@ public static class AdminSiteSettingsEndpoints
{
private const string FallbackMaintenanceTitle = "Sternenpause";
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
{
@@ -32,6 +33,14 @@ public static class AdminSiteSettingsEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminOperationalSettings")
.WithOpenApi();
group.MapGet("/optional-feature-settings", GetOptionalFeatureSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("GetAdminOptionalFeatureSettings")
.WithOpenApi();
group.MapPut("/optional-feature-settings", UpdateOptionalFeatureSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminOptionalFeatureSettings")
.WithOpenApi();
return group;
}
@@ -57,6 +66,8 @@ public static class AdminSiteSettingsEndpoints
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings)));
}
@@ -100,6 +111,8 @@ public static class AdminSiteSettingsEndpoints
settings.ContactContent = request.ContactContent.Trim();
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
settings.ShowactsContent = request.ShowactsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
@@ -133,7 +146,8 @@ public static class AdminSiteSettingsEndpoints
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
@@ -144,6 +158,7 @@ public static class AdminSiteSettingsEndpoints
ImprintUrl = imprintUrl,
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
ShowactsUrl = showactsUrl,
};
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
@@ -187,6 +202,108 @@ public static class AdminSiteSettingsEndpoints
public string ImprintUrl { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
public string ShowactsUrl { get; set; } = string.Empty;
}
private static async Task<IResult> GetOptionalFeatureSettings(AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
}
private static async Task<IResult> UpdateOptionalFeatureSettings(
HttpContext context,
UpdateOptionalFeatureSettingsRequest 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 before = ToOptionalFeatureSettingsResponse(settings);
var disabledMessage = NormalizeOptionalFeatureText(
request.ClipSubmissionDisabledMessage,
FallbackClipSubmissionDisabledMessage,
240);
var showactDisabledMessage = NormalizeOptionalFeatureText(
request.ShowactApplicationDisabledMessage,
"Showact-Bewerbungen sind aktuell geschlossen.",
240);
settings.ClipSubmissionsEnabled = request.ClipSubmissionsEnabled;
settings.ClipReviewEnabled = request.ClipReviewEnabled;
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
settings.ClipSubmissionDisabledMessage = disabledMessage;
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
settings.SponsorsVisible = request.SponsorsVisible;
var after = ToOptionalFeatureSettingsResponse(settings);
adminAuditService.AddEntry(
session.TwitchUserId,
"optional-features.update",
"site-settings",
settings.Id.ToString(),
"Optionale Workflow-Features wurden aktualisiert.",
new
{
before,
after,
changes = BuildOptionalFeatureChanges(before, after),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(after);
}
private static AdminOptionalFeatureSettingsResponse ToOptionalFeatureSettingsResponse(SiteSettings settings) =>
new(
settings.ClipSubmissionsEnabled,
settings.ClipReviewEnabled,
settings.ClipAdminMenuVisible,
string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage)
? FallbackClipSubmissionDisabledMessage
: settings.ClipSubmissionDisabledMessage,
settings.ShowactApplicationsEnabled,
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: settings.ShowactApplicationDisabledMessage,
settings.SponsorsVisible);
private static string NormalizeOptionalFeatureText(string? value, string fallback, int maxLength)
{
var trimmed = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return fallback;
}
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private static object[] BuildOptionalFeatureChanges(
AdminOptionalFeatureSettingsResponse before,
AdminOptionalFeatureSettingsResponse after)
{
var changes = new List<object>();
AddOperationalChange(changes, "clipSubmissionsEnabled", "Clip-Einreichung", before.ClipSubmissionsEnabled, after.ClipSubmissionsEnabled);
AddOperationalChange(changes, "clipReviewEnabled", "Clip-Review", before.ClipReviewEnabled, after.ClipReviewEnabled);
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
return changes.ToArray();
}
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
@@ -0,0 +1,87 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
}
private static async Task<IResult> UpdateWorkflowRules(
HttpContext context,
UpdateWorkflowRulesRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var before = WorkflowRuleSettings.Read(settings);
var mergedRules = WorkflowRuleSettings.Defaults
.Select(defaultRule =>
{
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
return requestRule is null
? defaultRule
: new WorkflowRuleSetting(
defaultRule.Key,
defaultRule.Label,
requestRule.Enabled,
requestRule.Limit,
requestRule.Mode,
defaultRule.Description);
})
.ToArray();
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
var after = WorkflowRuleSettings.Read(settings);
var changes = after
.Select(rule =>
{
var previous = before.First(item => item.Key == rule.Key);
return new
{
field = rule.Key,
label = rule.Label,
from = $"{previous.Enabled}/{previous.Limit}/{previous.Mode}",
to = $"{rule.Enabled}/{rule.Limit}/{rule.Mode}",
sensitive = false,
};
})
.Where(change => change.from != change.to)
.ToArray();
adminAuditService.AddEntry(
session.TwitchUserId,
"workflow-rules.update",
"site-settings",
settings.Id.ToString(),
"Workflow-Regeln wurden aktualisiert.",
new { changes },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new AdminWorkflowRulesResponse(after.Select(ToWorkflowRuleDto).ToArray()));
}
private static AdminWorkflowRuleDto ToWorkflowRuleDto(WorkflowRuleSetting rule) =>
new(rule.Key, rule.Label, rule.Enabled, rule.Limit, rule.Mode, rule.Description);
}
+16
View File
@@ -28,6 +28,22 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
}
var siteSettings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
if (siteSettings is null)
{
return Results.Problem("Site settings are missing.");
}
if (!siteSettings.ClipSubmissionsEnabled)
{
var message = string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
? "Clip-Einreichungen sind aktuell geschlossen."
: siteSettings.ClipSubmissionDisabledMessage;
return Results.BadRequest(new { message });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
if (clipSeasonResolution.Result is not null)
+9
View File
@@ -22,6 +22,10 @@ public static partial class PublicEndpoints
.WithName("GetWinnerArchive")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/sponsors", GetSponsors)
.WithName("GetPublicSponsors")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
.WithName("GetUserParticipation")
.WithOpenApi();
@@ -41,6 +45,11 @@ public static partial class PublicEndpoints
.WithName("CreateClip")
.WithOpenApi();
group.MapPost("/showacts", CreateShowactApplication)
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
.WithName("CreateShowactApplication")
.WithOpenApi();
return app;
}
}
+131
View File
@@ -0,0 +1,131 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Year == year);
if (season is null)
{
return Results.NotFound();
}
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null || !settings.SponsorsVisible)
{
return Results.Ok(new PublicSponsorsResponse(year, []));
}
var sponsors = await db.Sponsors
.AsNoTracking()
.Where(item => item.SeasonId == season.Id && item.IsVisible)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(item => new SponsorDto(
item.Id,
item.SeasonId,
item.Name,
item.WebsiteUrl,
item.LogoUrl,
item.Description,
item.Tier,
item.SortOrder,
item.IsVisible))
.ToArrayAsync();
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
}
private static async Task<IResult> CreateShowactApplication(
HttpContext context,
CreateShowactApplicationRequest request,
AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
if (settings is null || !settings.ShowactApplicationsEnabled)
{
return Results.BadRequest(new
{
message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: settings.ShowactApplicationDisabledMessage,
});
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted);
if (season is null)
{
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
}
var artistName = NormalizePublicText(request.ArtistName, 120);
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
var performanceType = NormalizePublicText(request.PerformanceType, 80);
var description = NormalizePublicText(request.Description, 1000);
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
if (string.IsNullOrWhiteSpace(artistName))
{
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 (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 });
}
private static string NormalizePublicText(string? value, int maxLength)
{
var trimmed = (value ?? string.Empty).Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private static bool IsBlankOrHttpUrl(string value) =>
string.IsNullOrWhiteSpace(value)
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
}
@@ -54,6 +54,17 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
}
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
var linkBlacklist = NominationLinkBlacklistSettings.Read(settings);
var blacklistedStreamUrl = submittedNominations
.Select(item => item.StreamUrl)
.FirstOrDefault(item => NominationLinkBlacklistSettings.IsBlocked(item, linkBlacklist));
if (blacklistedStreamUrl is not null)
{
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
}
var category = await db.Categories
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
+20 -1
View File
@@ -42,6 +42,10 @@ public static partial class PublicEndpoints
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
WinnerPlatform = result.Candidate.Platform,
ClipUrl = result.Candidate.ClipCompilationUrl,
ClipTitle = result.Candidate.ClipCompilationTitle,
ClipPlatform = result.Candidate.ClipCompilationPlatform,
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
})
.ToArrayAsync();
@@ -52,7 +56,11 @@ public static partial class PublicEndpoints
result.WinnerName,
result.WinnerSlug,
result.WinnerPlatform,
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
result.ClipUrl,
result.ClipTitle,
result.ClipPlatform,
result.ClipEmbedStatus))
.ToArray();
var archiveYearRows = await db.Results
@@ -110,6 +118,17 @@ public static partial class PublicEndpoints
siteSettings.PrivacyPolicyContent,
SeasonMappings.ReadSocialLinks(siteSettings),
SeasonMappings.BuildFooterLinks(siteSettings)),
new PublicFeatureFlagsDto(
siteSettings.ClipSubmissionsEnabled,
siteSettings.ClipReviewEnabled,
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
? "Clip-Einreichungen sind aktuell geschlossen."
: siteSettings.ClipSubmissionDisabledMessage,
siteSettings.ShowactApplicationsEnabled,
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: siteSettings.ShowactApplicationDisabledMessage,
siteSettings.SponsorsVisible),
SeasonMappings.ReadFaqItems(siteSettings));
return Results.Ok(response);
@@ -54,17 +54,36 @@ public static partial class PublicEndpoints
category.GroupName,
category.Description,
category.MaxNomineesPerUser,
category.Candidates.Select(candidate =>
category.Candidates
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
.Select(candidate =>
{
var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey);
var candidateClipUrl = string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)
|| string.Equals(candidate.ClipEmbedStatus, "blocked", StringComparison.OrdinalIgnoreCase)
? null
: candidate.ClipCompilationUrl.Trim();
var clipUrl = candidateClipUrl ?? clip?.ClipUrl;
var clipTitle = candidateClipUrl is not null
? string.IsNullOrWhiteSpace(candidate.ClipCompilationTitle) ? "Highlight-Clip ansehen" : candidate.ClipCompilationTitle.Trim()
: clip?.Title;
var clipPlatform = candidateClipUrl is not null
? string.IsNullOrWhiteSpace(candidate.ClipCompilationPlatform) ? candidate.Platform : candidate.ClipCompilationPlatform.Trim()
: clip?.Platform;
var clipEmbedStatus = candidateClipUrl is not null
? candidate.ClipEmbedStatus
: null;
return new CandidateSummaryDto(
candidate.Id,
candidate.DisplayName,
candidate.ChannelSlug,
SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug),
candidate.Platform,
clip?.ClipUrl,
clip?.Title,
clip?.Platform);
clipUrl,
clipTitle,
clipPlatform,
clipEmbedStatus);
}).ToArray()))
.ToArray()));
}
@@ -35,6 +35,10 @@ public static partial class PublicEndpoints
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
WinnerPlatform = result.Candidate.Platform,
ClipUrl = result.Candidate.ClipCompilationUrl,
ClipTitle = result.Candidate.ClipCompilationTitle,
ClipPlatform = result.Candidate.ClipCompilationPlatform,
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
})
.ToArrayAsync();
@@ -44,7 +48,11 @@ public static partial class PublicEndpoints
result.WinnerName,
result.WinnerSlug,
result.WinnerPlatform,
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
result.ClipUrl,
result.ClipTitle,
result.ClipPlatform,
result.ClipEmbedStatus))
.ToArray();
return Results.Ok(new WinnerArchiveResponse(year, items));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddCandidatePreparationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AcceptanceNote",
table: "Candidates",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AcceptanceStatus",
table: "Candidates",
type: "character varying(30)",
maxLength: 30,
nullable: false,
defaultValue: "open");
migrationBuilder.AddColumn<string>(
name: "ClipCompilationPlatform",
table: "Candidates",
type: "character varying(40)",
maxLength: 40,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClipCompilationTitle",
table: "Candidates",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClipCompilationUrl",
table: "Candidates",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClipEmbedStatus",
table: "Candidates",
type: "character varying(30)",
maxLength: 30,
nullable: false,
defaultValue: "unchecked");
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 3,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 4,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 5,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 6,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 7,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 8,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 9,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 10,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 11,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 12,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 13,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AcceptanceNote",
table: "Candidates");
migrationBuilder.DropColumn(
name: "AcceptanceStatus",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipCompilationPlatform",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipCompilationTitle",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipCompilationUrl",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipEmbedStatus",
table: "Candidates");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddWorkflowRulesJson : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
""");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
column: "WorkflowRulesJson",
value: "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"}]");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "WorkflowRulesJson";
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddOptionalClipFeatureSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true;
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.';
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false;
""");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "ClipReviewEnabled", "ClipSubmissionDisabledMessage" },
values: new object[] { true, "Clip-Einreichungen sind aktuell geschlossen." });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "ClipReviewEnabled";
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "ClipSubmissionDisabledMessage";
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "ClipSubmissionsEnabled";
""");
}
}
}
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 AddNominationLinkBlacklist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NominationLinkBlacklistJson",
table: "SiteSettings",
type: "text",
nullable: false,
defaultValue: "[]");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
column: "NominationLinkBlacklistJson",
value: "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NominationLinkBlacklistJson",
table: "SiteSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddClipAdminMenuVisible : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ClipAdminMenuVisible",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ShowactApplicationDisabledMessage",
table: "SiteSettings",
type: "character varying(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "ShowactApplicationsEnabled",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ShowactsContent",
table: "SiteSettings",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ShowactsUrl",
table: "SiteSettings",
type: "character varying(400)",
maxLength: 400,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "SponsorsVisible",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.CreateTable(
name: "ShowactApplications",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
ArtistName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
ContactEmail = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
ContactDiscord = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
PlatformUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
PerformanceType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
TechnicalNotes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
ReferenceUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ReviewNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
ReviewedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
CreatedFromIp = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
UserAgent = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShowactApplications", x => x.Id);
table.ForeignKey(
name: "FK_ShowactApplications_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Sponsors",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
WebsiteUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
LogoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Tier = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsVisible = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Sponsors", x => x.Id);
table.ForeignKey(
name: "FK_Sponsors_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "ClipAdminMenuVisible", "ShowactApplicationDisabledMessage", "ShowactsContent", "ShowactsUrl", "SponsorsVisible", "WorkflowRulesJson" },
values: new object[] { true, "Showact-Bewerbungen sind aktuell geschlossen.", "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", "https://vtuber-star-awards.de/showacts", true, "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" });
migrationBuilder.CreateIndex(
name: "IX_ShowactApplications_SeasonId_Status",
table: "ShowactApplications",
columns: new[] { "SeasonId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Sponsors_SeasonId_IsVisible_SortOrder",
table: "Sponsors",
columns: new[] { "SeasonId", "IsVisible", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ShowactApplications");
migrationBuilder.DropTable(
name: "Sponsors");
migrationBuilder.DropColumn(
name: "ClipAdminMenuVisible",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactApplicationDisabledMessage",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactApplicationsEnabled",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactsContent",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactsUrl",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "SponsorsVisible",
table: "SiteSettings");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
column: "WorkflowRulesJson",
value: "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"}]");
}
}
}
@@ -169,6 +169,17 @@ namespace Backend.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AcceptanceNote")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("AcceptanceStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasDefaultValue("open");
b.Property<int>("CategoryId")
.HasColumnType("integer");
@@ -177,6 +188,25 @@ namespace Backend.Migrations
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("ClipCompilationPlatform")
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("ClipCompilationTitle")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClipCompilationUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ClipEmbedStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasDefaultValue("unchecked");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
@@ -202,8 +232,10 @@ namespace Backend.Migrations
new
{
Id = 1,
AcceptanceStatus = "open",
CategoryId = 1,
ChannelSlug = "@hoshimimiyu",
ClipEmbedStatus = "unchecked",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 1
@@ -211,8 +243,10 @@ namespace Backend.Migrations
new
{
Id = 2,
AcceptanceStatus = "open",
CategoryId = 1,
ChannelSlug = "@kurainu",
ClipEmbedStatus = "unchecked",
DisplayName = "Kurainu",
Platform = "Twitch",
SeasonId = 1
@@ -220,8 +254,10 @@ namespace Backend.Migrations
new
{
Id = 3,
AcceptanceStatus = "open",
CategoryId = 1,
ChannelSlug = "@shiroch",
ClipEmbedStatus = "unchecked",
DisplayName = "Shiro Ch.",
Platform = "Twitch",
SeasonId = 1
@@ -229,8 +265,10 @@ namespace Backend.Migrations
new
{
Id = 4,
AcceptanceStatus = "open",
CategoryId = 2,
ChannelSlug = "@kurainu",
ClipEmbedStatus = "unchecked",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 1
@@ -238,8 +276,10 @@ namespace Backend.Migrations
new
{
Id = 5,
AcceptanceStatus = "open",
CategoryId = 2,
ChannelSlug = "@aoisakura",
ClipEmbedStatus = "unchecked",
DisplayName = "Aoi Sakura Showcase",
Platform = "YouTube",
SeasonId = 1
@@ -247,8 +287,10 @@ namespace Backend.Migrations
new
{
Id = 6,
AcceptanceStatus = "open",
CategoryId = 3,
ChannelSlug = "@pyonkichikingdom",
ClipEmbedStatus = "unchecked",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 1
@@ -256,8 +298,10 @@ namespace Backend.Migrations
new
{
Id = 7,
AcceptanceStatus = "open",
CategoryId = 4,
ChannelSlug = "@moonrelay",
ClipEmbedStatus = "unchecked",
DisplayName = "Moonrelay",
Platform = "Twitch",
SeasonId = 1
@@ -265,8 +309,10 @@ namespace Backend.Migrations
new
{
Id = 8,
AcceptanceStatus = "open",
CategoryId = 5,
ChannelSlug = "@hoshimimiyu",
ClipEmbedStatus = "unchecked",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 2
@@ -274,8 +320,10 @@ namespace Backend.Migrations
new
{
Id = 9,
AcceptanceStatus = "open",
CategoryId = 6,
ChannelSlug = "@kurainu",
ClipEmbedStatus = "unchecked",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 2
@@ -283,8 +331,10 @@ namespace Backend.Migrations
new
{
Id = 10,
AcceptanceStatus = "open",
CategoryId = 7,
ChannelSlug = "@pyonkichikingdom",
ClipEmbedStatus = "unchecked",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 2
@@ -292,8 +342,10 @@ namespace Backend.Migrations
new
{
Id = 11,
AcceptanceStatus = "open",
CategoryId = 8,
ChannelSlug = "@aoisakura",
ClipEmbedStatus = "unchecked",
DisplayName = "Aoi Sakura",
Platform = "YouTube",
SeasonId = 3
@@ -301,8 +353,10 @@ namespace Backend.Migrations
new
{
Id = 12,
AcceptanceStatus = "open",
CategoryId = 9,
ChannelSlug = "@starbyte",
ClipEmbedStatus = "unchecked",
DisplayName = "Starbyte",
Platform = "Twitch",
SeasonId = 3
@@ -310,8 +364,10 @@ namespace Backend.Migrations
new
{
Id = 13,
AcceptanceStatus = "open",
CategoryId = 10,
ChannelSlug = "@tenshivox",
ClipEmbedStatus = "unchecked",
DisplayName = "Tenshi Vox",
Platform = "Twitch",
SeasonId = 4
@@ -844,6 +900,93 @@ namespace Backend.Migrations
});
});
modelBuilder.Entity("Backend.Domain.ShowactApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ArtistName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("ContactDiscord")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("ContactEmail")
.IsRequired()
.HasMaxLength(180)
.HasColumnType("character varying(180)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedFromIp")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("PerformanceType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("PlatformUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReferenceUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReviewNote")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset?>("ReviewedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReviewedByTwitchId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TechnicalNotes")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("UserAgent")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.HasKey("Id");
b.HasIndex("SeasonId", "Status");
b.ToTable("ShowactApplications");
});
modelBuilder.Entity("Backend.Domain.SiteSettings", b =>
{
b.Property<int>("Id")
@@ -852,6 +995,24 @@ namespace Backend.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ClipAdminMenuVisible")
.HasColumnType("boolean");
b.Property<bool>("ClipReviewEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("ClipSubmissionDisabledMessage")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<bool>("ClipSubmissionsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("ContactContent")
.IsRequired()
.HasColumnType("text");
@@ -933,6 +1094,12 @@ namespace Backend.Migrations
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("NominationLinkBlacklistJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("[]");
b.Property<string>("PrivacyEmail")
.IsRequired()
.HasMaxLength(160)
@@ -953,6 +1120,27 @@ namespace Backend.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("ShowactApplicationDisabledMessage")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<bool>("ShowactApplicationsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("ShowactsContent")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("");
b.Property<string>("ShowactsUrl")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("SocialLinksJson")
.IsRequired()
.HasColumnType("text");
@@ -966,6 +1154,11 @@ namespace Backend.Migrations
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<bool>("SponsorsVisible")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("TwitchAuthManagedByDatabase")
.HasColumnType("boolean");
@@ -989,6 +1182,12 @@ namespace Backend.Migrations
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("WorkflowRulesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("[]");
b.HasKey("Id");
b.ToTable("SiteSettings");
@@ -997,6 +1196,10 @@ namespace Backend.Migrations
new
{
Id = 1,
ClipAdminMenuVisible = true,
ClipReviewEnabled = true,
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
ClipSubmissionsEnabled = false,
ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.",
ContactUrl = "https://vtuber-star-awards.de/kontakt",
DemoLoginDisplayName = "Jayuhime Admin",
@@ -1015,22 +1218,84 @@ namespace Backend.Migrations
MaintenanceModeEnabled = false,
MaintenanceTitle = "Sternenpause",
NewsletterUrl = "https://vtuber-star-awards.de/newsletter",
NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]",
PrivacyEmail = "datenschutz@vtuber-star-awards.de",
PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.",
PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
PrivacyPolicyUpdatedBy = "seed",
RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]",
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
ShowactApplicationsEnabled = false,
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]",
SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.",
SponsorsUrl = "https://vtuber-star-awards.de/partner",
SponsorsVisible = true,
TwitchAuthManagedByDatabase = false,
TwitchClientId = "",
TwitchClientSecret = "",
TwitchRedirectUri = "",
TwitchScope = ""
TwitchScope = "",
WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]"
});
});
modelBuilder.Entity("Backend.Domain.Sponsor", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsVisible")
.HasColumnType("boolean");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Tier")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("SeasonId", "IsVisible", "SortOrder");
b.ToTable("Sponsors");
});
modelBuilder.Entity("Backend.Domain.TeamMember", b =>
{
b.Property<int>("Id")
@@ -1408,6 +1673,28 @@ namespace Backend.Migrations
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.ShowactApplication", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Sponsor", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
@@ -0,0 +1,121 @@
using System.Text.Json;
using Backend.Domain;
namespace Backend.Services;
public sealed record NominationLinkBlacklistEntry(string Url);
public static class NominationLinkBlacklistSettings
{
public static readonly NominationLinkBlacklistEntry[] Defaults =
[
new("https://www.twitch.tv/"),
new("https://kick.com/"),
new("https://www.youtube.com/"),
];
public static NominationLinkBlacklistEntry[] Read(SiteSettings? settings)
{
var entries = Parse(settings?.NominationLinkBlacklistJson);
return entries.Length > 0 ? entries : Defaults;
}
public static string Serialize(IEnumerable<NominationLinkBlacklistEntry> entries)
{
var normalizedEntries = entries
.Select(entry => NormalizeEntry(entry.Url))
.Where(entry => entry is not null)
.Select(entry => new NominationLinkBlacklistEntry(entry!))
.DistinctBy(entry => BuildComparisonKey(entry.Url), StringComparer.OrdinalIgnoreCase)
.OrderBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase)
.ToArray();
return JsonSerializer.Serialize(normalizedEntries);
}
public static bool TryNormalizeUrl(string? rawUrl, out string normalizedUrl)
{
normalizedUrl = NormalizeEntry(rawUrl) ?? string.Empty;
return !string.IsNullOrWhiteSpace(normalizedUrl);
}
public static bool IsBlocked(string rawUrl, IEnumerable<NominationLinkBlacklistEntry> entries)
{
var submittedKey = BuildComparisonKey(rawUrl);
return !string.IsNullOrWhiteSpace(submittedKey)
&& entries.Any(entry => string.Equals(BuildComparisonKey(entry.Url), submittedKey, StringComparison.OrdinalIgnoreCase));
}
private static NominationLinkBlacklistEntry[] Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
var entries = JsonSerializer.Deserialize<NominationLinkBlacklistEntry[]>(json);
return entries?.Where(entry => !string.IsNullOrWhiteSpace(entry.Url)).ToArray() ?? [];
}
catch (JsonException)
{
return [];
}
}
private static string? NormalizeEntry(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!candidate.Contains("://", StringComparison.Ordinal))
{
candidate = $"https://{candidate}";
}
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host))
{
return null;
}
var builder = new UriBuilder(uri)
{
Scheme = Uri.UriSchemeHttps,
Host = uri.Host.ToLowerInvariant(),
Port = -1,
Query = string.Empty,
Fragment = string.Empty,
};
var normalized = builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped);
return normalized.EndsWith('/') ? normalized : $"{normalized}/";
}
private static string BuildComparisonKey(string? rawUrl)
{
if (NormalizeEntry(rawUrl) is not { } normalized)
{
return string.Empty;
}
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri))
{
return string.Empty;
}
var host = uri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)
? uri.Host[4..]
: uri.Host;
var path = uri.AbsolutePath.TrimEnd('/');
return $"{host.ToLowerInvariant()}{path.ToLowerInvariant()}";
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Text.Json;
using Backend.Domain;
namespace Backend.Services;
public sealed record WorkflowRuleSetting(
string Key,
string Label,
bool Enabled,
int Limit,
string Mode,
string Description);
public static class WorkflowRuleSettings
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public const string MaxFinalistsPerCategory = "max_finalists_per_category";
public const string MaxCandidateAppearances = "max_candidate_appearances";
public const string MaxWinnerPlacements = "max_winner_placements";
public const string WinnerRequiresClip = "winner_requires_clip";
public static WorkflowRuleSetting[] Defaults { get; } =
[
new(MaxFinalistsPerCategory, "Finale Kandidat:innen pro Kategorie", true, 4, "block", "Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden."),
new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."),
new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."),
new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."),
];
public static WorkflowRuleSetting[] Read(SiteSettings? settings)
{
var storedRules = Parse(settings?.WorkflowRulesJson);
return Defaults
.Select(defaultRule =>
{
var storedRule = storedRules.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
return storedRule is null ? defaultRule : Normalize(storedRule, defaultRule);
})
.ToArray();
}
public static string Serialize(IEnumerable<WorkflowRuleSetting> rules) =>
JsonSerializer.Serialize(rules.Select(rule => Normalize(rule, Defaults.FirstOrDefault(item => item.Key == rule.Key) ?? rule)), JsonOptions);
public static WorkflowRuleSetting Find(IEnumerable<WorkflowRuleSetting> rules, string key) =>
rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
?? Defaults.First(item => item.Key == key);
public static bool ShouldBlock(WorkflowRuleSetting rule) =>
rule.Enabled && string.Equals(rule.Mode, "block", StringComparison.OrdinalIgnoreCase);
public static string CandidateIdentityKey(Candidate candidate)
{
var channel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(channel))
{
return $"slug:{channel}";
}
return $"name:{candidate.DisplayName.Trim().ToLowerInvariant()}";
}
public static string CandidateIdentityKey(string displayName, string channelSlug)
{
var channel = channelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(channel))
{
return $"slug:{channel}";
}
return $"name:{displayName.Trim().ToLowerInvariant()}";
}
private static WorkflowRuleSetting[] Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<WorkflowRuleSetting[]>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static WorkflowRuleSetting Normalize(WorkflowRuleSetting rule, WorkflowRuleSetting fallback)
{
var mode = rule.Mode.Trim().ToLowerInvariant() switch
{
"block" => "block",
"warn" => "warn",
_ => fallback.Mode,
};
return rule with
{
Key = fallback.Key,
Label = string.IsNullOrWhiteSpace(rule.Label) ? fallback.Label : rule.Label.Trim(),
Enabled = rule.Enabled,
Limit = Math.Clamp(rule.Limit, 1, 50),
Mode = mode,
Description = string.IsNullOrWhiteSpace(rule.Description) ? fallback.Description : rule.Description.Trim(),
};
}
}