Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
+9
View File
@@ -6,3 +6,12 @@ Backend/obj/
# Local Claude Code config (settings, preview launch configs) # Local Claude Code config (settings, preview launch configs)
.claude/ .claude/
# Local design/prototype exports and audit screenshots
.design-source/
.od-skills/
audit/
*.dc.html
*.dc.html.artifact.json
/frontend/public/*.html
/*-drawing-*.png
+11 -1
View File
@@ -1 +1,11 @@
VTSA_POSTGRES=Host=localhost;Port=5432;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres VTSA_POSTGRES=Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only
ConnectionStrings__Postgres=Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only
Frontend__AllowedOrigins__0=http://localhost:5173
Frontend__AllowedOrigins__1=http://127.0.0.1:5173
VTSA_SEED_MODE=demo
VTSA_DEMO_LOGIN_ENABLED=true
VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin
VTSA_DEMO_ADMIN_EMAIL=admin@example.local
VTSA_DEMO_ADMIN_PASSWORD=change-me-for-demo
VTSA_DEMO_ADMIN_TWITCH_ID=jayuhime_admin
VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin
+16
View File
@@ -0,0 +1,16 @@
namespace Backend.Common;
public static class ApplicationDefaults
{
public static readonly string[] FrontendOrigins =
[
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173",
];
public const string FrontendCorsPolicy = "frontend";
public const string AuthRateLimitPolicy = "auth";
public const string PublicWriteRateLimitPolicy = "public-write";
}
+3
View File
@@ -0,0 +1,3 @@
namespace Backend.Common;
public sealed record RequestMetadata(string ClientIp, string UserAgent);
+16
View File
@@ -0,0 +1,16 @@
namespace Backend.Common;
public static class RequestMetadataReader
{
public static RequestMetadata Read(HttpContext context) =>
new(ReadClientIp(context), ReadUserAgent(context));
public static string ReadClientIp(HttpContext context) =>
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
public static string ReadUserAgent(HttpContext context)
{
var value = context.Request.Headers.UserAgent.ToString().Trim();
return value.Length > 400 ? value[..400] : value;
}
}
+188
View File
@@ -0,0 +1,188 @@
using System.Text.Json;
using Backend.Contracts;
using Backend.Domain;
namespace Backend.Common;
public static class SeasonMappings
{
public static bool IsSeasonScheduleValid(
DateOnly nominationStartsAt,
DateOnly nominationEndsAt,
DateOnly votingStartsAt,
DateOnly votingEndsAt,
DateOnly reviewStartsAt,
DateOnly reviewEndsAt,
DateOnly showDate)
{
return nominationStartsAt <= nominationEndsAt
&& nominationEndsAt <= votingStartsAt
&& votingStartsAt <= votingEndsAt
&& votingEndsAt <= reviewStartsAt
&& reviewStartsAt <= reviewEndsAt
&& reviewEndsAt <= showDate;
}
public static string BuildProfileUrl(string platform, string channelSlug)
{
var normalizedPlatform = platform.Trim().ToLowerInvariant();
var platformKey = new string(normalizedPlatform.Where(char.IsLetterOrDigit).ToArray());
var slug = channelSlug.Trim();
var cleanSlug = slug.TrimStart('@');
if (slug.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
slug.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return slug;
}
if (string.IsNullOrWhiteSpace(cleanSlug))
{
return "#";
}
if (string.IsNullOrWhiteSpace(platformKey))
{
return cleanSlug.Contains('.') ? $"https://{cleanSlug}" : "#";
}
return platformKey switch
{
"artstation" => $"https://www.artstation.com/{cleanSlug}",
"bilibili" => $"https://space.bilibili.com/{cleanSlug}",
"bluesky" => $"https://bsky.app/profile/{cleanSlug}",
"booth" => $"https://{cleanSlug}.booth.pm",
"cake" => $"https://cake.gg/@{cleanSlug}",
"discord" => cleanSlug.Contains("discord.", StringComparison.OrdinalIgnoreCase) ? $"https://{cleanSlug}" : $"https://discord.gg/{cleanSlug}",
"deviantart" => $"https://www.deviantart.com/{cleanSlug}",
"facebook" => $"https://facebook.com/{cleanSlug}",
"fanbox" => $"https://{cleanSlug}.fanbox.cc",
"github" => $"https://github.com/{cleanSlug}",
"instagram" => $"https://instagram.com/{cleanSlug}",
"kick" => $"https://kick.com/{cleanSlug}",
"kofi" or "ko-fi" => $"https://ko-fi.com/{cleanSlug}",
"linktree" => $"https://linktr.ee/{cleanSlug}",
"mastodon" => cleanSlug.Contains('@') ? $"https://{cleanSlug.Split('@').Last()}/@{cleanSlug.Split('@').First()}" : $"https://mastodon.social/@{cleanSlug}",
"patreon" => $"https://patreon.com/{cleanSlug}",
"picarto" or "picartotv" => $"https://picarto.tv/{cleanSlug}",
"pinterest" => $"https://pinterest.com/{cleanSlug}",
"pixiv" => $"https://www.pixiv.net/users/{cleanSlug}",
"reddit" => $"https://reddit.com/user/{cleanSlug}",
"skeb" => $"https://skeb.jp/@{cleanSlug}",
"soundcloud" => $"https://soundcloud.com/{cleanSlug}",
"spotify" => $"https://open.spotify.com/user/{cleanSlug}",
"telegram" => $"https://t.me/{cleanSlug}",
"threads" => $"https://threads.net/@{cleanSlug}",
"tiktok" => $"https://tiktok.com/@{cleanSlug}",
"trovo" => $"https://trovo.live/s/{cleanSlug}",
"twitch" => $"https://twitch.tv/{cleanSlug}",
"tumblr" => $"https://{cleanSlug}.tumblr.com",
"vimeo" => $"https://vimeo.com/{cleanSlug}",
"website" or "link" => cleanSlug.Contains('.') ? $"https://{cleanSlug}" : $"https://{cleanSlug}.com",
"youtube" => cleanSlug.StartsWith("@", StringComparison.Ordinal) ? $"https://youtube.com/{cleanSlug}" : $"https://youtube.com/@{cleanSlug}",
"x" or "twitter" => $"https://x.com/{cleanSlug}",
_ => $"https://{platformKey}.com/{cleanSlug}",
};
}
public static string NormalizeSeasonStreamUrl(string? value)
{
var trimmed = value?.Trim() ?? string.Empty;
return string.IsNullOrWhiteSpace(trimmed) ? "https://twitch.tv/jayuhime" : trimmed;
}
public static string NormalizePhaseKey(string? currentPhase)
{
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
if (value.Contains("abgeschlossen") || value.Contains("archiv") || value.Contains("complete") || value.Contains("ended"))
{
return "completed";
}
if (value.Contains("show"))
{
return "show";
}
if (value.Contains("review") || value.Contains("auswert"))
{
return "review";
}
if (value.Contains("vot"))
{
return "voting";
}
if (value.Contains("nomin"))
{
return "nomination";
}
return "nomination";
}
public static string ResolveTimelineState(string itemKey, string currentPhaseKey)
{
string[] phaseOrder = ["nomination", "voting", "review", "show"];
if (string.Equals(currentPhaseKey, "completed", StringComparison.OrdinalIgnoreCase))
{
return phaseOrder.Contains(itemKey) ? "done" : "upcoming";
}
var itemIndex = Array.IndexOf(phaseOrder, itemKey);
var currentIndex = Array.IndexOf(phaseOrder, currentPhaseKey);
if (itemIndex < 0)
{
return "upcoming";
}
if (currentIndex < 0)
{
currentIndex = 0;
}
if (itemIndex < currentIndex)
{
return "done";
}
if (itemIndex == currentIndex)
{
return "active";
}
return "upcoming";
}
public static T[] DeserializeSiteArray<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<T[]>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
}
catch
{
return [];
}
}
public static PublicSocialLinkDto[] ReadSocialLinks(SiteSettings settings) =>
DeserializeSiteArray<PublicSocialLinkDto>(settings.SocialLinksJson);
public static FaqItemDto[] ReadFaqItems(SiteSettings settings) =>
DeserializeSiteArray<FaqItemDto>(settings.FaqJson);
public static FooterLinkDto[] BuildFooterLinks(SiteSettings settings) =>
[
new FooterLinkDto("Impressum", settings.ImprintUrl),
new FooterLinkDto("Kontakt", settings.ContactUrl),
new FooterLinkDto("Sponsoren & Partner", settings.SponsorsUrl),
];
}
+8
View File
@@ -0,0 +1,8 @@
namespace Backend.Configuration;
public sealed class FrontendOptions
{
public const string SectionName = "Frontend";
public string[] AllowedOrigins { get; init; } = [];
}
-115
View File
@@ -1,115 +0,0 @@
namespace Backend.Contracts;
public sealed record AdminMetricDto(string Label, int Value, string Note);
public sealed record AdminActivityDto(string Label, string Age);
public sealed record AdminTopCategoryDto(string Category, int Votes);
public sealed record AdminRiskFlagDto(
int Id,
string Source,
string Type,
string Severity,
string Status,
string Summary,
string? TwitchUserId,
string CreatedFromIp,
DateTimeOffset CreatedAt,
string MetadataJson);
public sealed record AdminAuditEntryDto(
int Id,
string AdminTwitchUserId,
string ActionType,
string EntityType,
string EntityId,
string Summary,
DateTimeOffset CreatedAt);
public sealed record AdminDashboardResponse(
IEnumerable<AdminMetricDto> Metrics,
IEnumerable<AdminActivityDto> Activities,
IEnumerable<AdminTopCategoryDto> TopCategories,
IEnumerable<AdminRiskFlagDto> RiskFlags,
IEnumerable<AdminAuditEntryDto> AuditEntries);
public sealed record AdminSeasonListItemDto(
int Id,
int Year,
string Name,
string CurrentPhase,
bool IsCurrent,
int CategoryCount);
public sealed record AdminCategoryItemDto(
int Id,
string GroupName,
string Name,
string Slug,
string Description,
int SortOrder,
int MaxNomineesPerUser,
int CandidateCount);
public sealed record AdminCandidateItemDto(
int Id,
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record AdminNominationReviewItemDto(
int Id,
int CategoryId,
string CategoryName,
string SubmittedByTwitchId,
string CandidateText,
DateTimeOffset CreatedAt);
public sealed record AdminClipSubmissionItemDto(
int Id,
int? CategoryId,
string SubmittedByTwitchId,
string ClipUrl,
string Title,
string Creator,
string Platform,
string Status,
DateTimeOffset CreatedAt);
public sealed record AdminSeasonDetailResponse(
int Id,
int Year,
string Name,
string CurrentPhase,
bool IsCurrent,
IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
public sealed record UpdateSeasonRequest(
string CurrentPhase,
bool IsCurrent);
public sealed record UpsertCategoryRequest(
string GroupName,
string Name,
string Slug,
string Description,
int SortOrder,
int MaxNomineesPerUser);
public sealed record UpsertCandidateRequest(
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record ApproveNominationRequest(
string? DisplayName,
string? ChannelSlug,
string? Platform);
public sealed record ResolveRiskFlagRequest(string Status);
@@ -0,0 +1,14 @@
namespace Backend.Contracts;
public sealed record AdminMetricDto(string Label, int Value, string Note);
public sealed record AdminActivityDto(string Label, string Age);
public sealed record AdminTopCategoryDto(string Category, int Votes);
public sealed record AdminDashboardResponse(
IEnumerable<AdminMetricDto> Metrics,
IEnumerable<AdminActivityDto> Activities,
IEnumerable<AdminTopCategoryDto> TopCategories,
IEnumerable<AdminRiskFlagDto> RiskFlags,
IEnumerable<AdminAuditEntryDto> AuditEntries);
@@ -0,0 +1,118 @@
namespace Backend.Contracts;
public sealed record AdminRiskFlagDto(
int Id,
string Source,
string Type,
string Severity,
string Status,
string Summary,
string? TwitchUserId,
string CreatedFromIp,
DateTimeOffset CreatedAt,
string MetadataJson,
string? ReviewNote,
string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt,
AdminRiskEntityLinkDto[] EntityLinks);
public sealed record AdminRiskEntityLinkDto(
string Label,
string EntityType,
string EntityId,
string To);
public sealed record AdminRiskCountDto(string Key, int Count);
public sealed record AdminRiskFlagsResponse(
AdminRiskFlagDto[] Items,
int TotalCount,
int ReturnedCount,
int Offset,
int Limit,
bool HasMore,
AdminRiskCountDto[] SeverityCounts,
AdminRiskCountDto[] StatusCounts);
public sealed record AdminAuditEntryDto(
int Id,
string AdminTwitchUserId,
string ActionType,
string EntityType,
string EntityId,
string Summary,
DateTimeOffset CreatedAt,
string MetadataJson,
string CreatedFromIp,
string UserAgent);
public sealed record AdminAuditEntriesResponse(
AdminAuditEntryDto[] Items,
int TotalCount,
int ReturnedCount,
string? NextCursor,
int Limit);
public sealed record AdminNominationReviewItemDto(
int Id,
int CategoryId,
string CategoryName,
string SubmittedByTwitchId,
string CandidateText,
string? StreamUrl,
string Status,
DateTimeOffset CreatedAt,
int? CandidateId,
string? CandidateDisplayName,
string? ReviewNote,
string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt);
public sealed record AdminClipSubmissionItemDto(
int Id,
int? CategoryId,
int? CandidateId,
string SubmittedByTwitchId,
string ClipUrl,
string Title,
string Creator,
string Platform,
string Status,
DateTimeOffset CreatedAt,
string? ReviewNote,
string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt);
public sealed record ApproveNominationRequest(
string? DisplayName,
string? ChannelSlug,
string? Platform,
string? ReviewNote);
public sealed record RejectNominationRequest(string? ReviewNote);
public sealed record UpdateClipStatusRequest(
string Status,
string? ReviewNote);
public sealed record ResolveRiskFlagRequest(
string Status,
string? ReviewNote);
public sealed record BulkResolveRiskFlagsRequest(
int[] RiskFlagIds,
string Status,
string? ReviewNote);
public sealed record AdminRiskRuleDto(
string Key,
string Label,
bool Enabled,
int Threshold,
int WindowMinutes,
string Severity,
string Description);
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
public sealed record UpdateRiskRulesRequest(AdminRiskRuleDto[] Rules);
+109
View File
@@ -0,0 +1,109 @@
namespace Backend.Contracts;
public sealed record AdminSeasonListItemDto(
int Id,
int Year,
string Name,
string CurrentPhase,
bool IsCurrent,
int CategoryCount);
public sealed record AdminCategoryItemDto(
int Id,
string GroupName,
string Name,
string Slug,
string Description,
int SortOrder,
int MaxNomineesPerUser,
int CandidateCount);
public sealed record AdminCandidateItemDto(
int Id,
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record AdminAwardResultItemDto(
int Id,
int CategoryId,
string CategoryName,
int CandidateId,
string CandidateDisplayName,
string CandidateChannelSlug,
string CandidatePlatform);
public sealed record AdminSeasonDetailResponse(
int Id,
int Year,
string Name,
string ShowStreamUrl,
string CurrentPhase,
bool IsCurrent,
bool IsCommunityOnly,
DateOnly NominationStartsAt,
DateOnly NominationEndsAt,
DateOnly VotingStartsAt,
DateOnly VotingEndsAt,
DateOnly ReviewStartsAt,
DateOnly ReviewEndsAt,
DateOnly ShowDate,
TimeOnly ShowStartsAt,
IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
IEnumerable<AdminAwardResultItemDto> Results,
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
public sealed record CreateSeasonRequest(
int Year,
string Name,
string ShowStreamUrl,
string CurrentPhase,
bool IsCurrent,
bool IsCommunityOnly,
DateOnly NominationStartsAt,
DateOnly NominationEndsAt,
DateOnly VotingStartsAt,
DateOnly VotingEndsAt,
DateOnly ReviewStartsAt,
DateOnly ReviewEndsAt,
DateOnly ShowDate,
TimeOnly ShowStartsAt,
int? CopyStructureFromSeasonId = null);
public sealed record UpdateSeasonRequest(
int Year,
string Name,
string ShowStreamUrl,
string CurrentPhase,
bool IsCurrent,
bool IsCommunityOnly,
DateOnly NominationStartsAt,
DateOnly NominationEndsAt,
DateOnly VotingStartsAt,
DateOnly VotingEndsAt,
DateOnly ReviewStartsAt,
DateOnly ReviewEndsAt,
DateOnly ShowDate,
TimeOnly ShowStartsAt);
public sealed record UpsertCategoryRequest(
string GroupName,
string Name,
string Slug,
string Description,
int SortOrder,
int MaxNomineesPerUser);
public sealed record UpsertCandidateRequest(
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record SetAwardResultRequest(
int CategoryId,
int CandidateId);
@@ -0,0 +1,48 @@
namespace Backend.Contracts;
public sealed record AdminSiteSettingsResponse(
string HostDisplayName,
string HostTagline,
string NewsletterUrl,
string PrivacyEmail,
string PrivacyPolicyContent,
string? PrivacyPolicyUpdatedBy,
DateTimeOffset? PrivacyPolicyUpdatedAt,
string ImprintUrl,
string ContactUrl,
string SponsorsUrl,
IEnumerable<PublicSocialLinkDto> SocialLinks,
IEnumerable<FaqItemDto> Faq);
public sealed record UpdateSiteSettingsRequest(
string HostDisplayName,
string HostTagline,
string NewsletterUrl,
string PrivacyEmail,
string PrivacyPolicyContent,
string ImprintUrl,
string ContactUrl,
string SponsorsUrl,
PublicSocialLinkDto[] SocialLinks,
FaqItemDto[] Faq);
public sealed record AdminOperationalSettingsResponse(
bool DemoLoginManagedByDatabase,
bool DemoLoginEnabled,
string DemoLoginEmail,
bool DemoLoginPasswordSet,
string DemoLoginTwitchUserId,
string DemoLoginDisplayName,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
public sealed record UpdateOperationalSettingsRequest(
bool DemoLoginEnabled,
string DemoLoginEmail,
string? DemoLoginPassword,
string DemoLoginTwitchUserId,
string DemoLoginDisplayName,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
+5
View File
@@ -5,6 +5,11 @@ public sealed record LoginRequest(
string DisplayName, string DisplayName,
string Role); string Role);
public sealed record DemoLoginRequest(
string? Login,
string? Email,
string? Password);
public sealed record AuthSessionDto( public sealed record AuthSessionDto(
string SessionToken, string SessionToken,
string TwitchUserId, string TwitchUserId,
-87
View File
@@ -1,87 +0,0 @@
namespace Backend.Contracts;
public sealed record TimelineItem(
string Key,
string Title,
DateOnly StartsAt,
DateOnly EndsAt,
string State);
public sealed record FeaturedCategoryDto(
int Id,
string GroupName,
string Name,
string Description,
int MaxNomineesPerUser);
public sealed record WinnerPreviewDto(
int Year,
string Category,
string WinnerName,
string WinnerSlug);
public sealed record FaqItemDto(string Question, string Answer);
public sealed record OverviewResponse(
int SeasonId,
int Year,
string Title,
DateOnly ShowDate,
string CurrentPhase,
bool IsCommunityOnly,
string LoginProvider,
IEnumerable<TimelineItem> Timeline,
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
IEnumerable<WinnerPreviewDto> WinnersPreview,
IEnumerable<FaqItemDto> Faq);
public sealed record CandidateSummaryDto(
int Id,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record PublicCategoryDetailDto(
int Id,
string Name,
string GroupName,
string Description,
int MaxNomineesPerUser,
IEnumerable<CandidateSummaryDto> Candidates);
public sealed record SeasonCategoriesResponse(
int SeasonId,
int Year,
IEnumerable<PublicCategoryDetailDto> Categories);
public sealed record WinnerArchiveItemDto(
string Category,
string WinnerName,
string WinnerSlug);
public sealed record WinnerArchiveResponse(
int Year,
IEnumerable<WinnerArchiveItemDto> Items);
public sealed record CreateNominationRequest(
int Year,
int CategoryId,
string TwitchUserId,
string[] Nominees);
public sealed record VoteEntryRequest(
int CategoryId,
int CandidateId);
public sealed record CreateVoteRequest(
int SeasonId,
string TwitchUserId,
VoteEntryRequest[] Entries);
public sealed record CreateClipRequest(
int Year,
int? CategoryId,
string TwitchUserId,
string ClipUrl,
string Title,
string Creator);
@@ -0,0 +1,68 @@
namespace Backend.Contracts;
public sealed record TimelineItem(
string Key,
string Title,
DateOnly StartsAt,
DateOnly EndsAt,
string State);
public sealed record FeaturedCategoryDto(
int Id,
string GroupName,
string Name,
string Description,
int MaxNomineesPerUser);
public sealed record WinnerPreviewDto(
int Year,
string Category,
string WinnerName,
string WinnerSlug,
string WinnerPlatform,
string WinnerUrl);
public sealed record FaqItemDto(string Question, string Answer);
public sealed record PublicSocialLinkDto(
string Label,
string Platform,
string Url,
string? Icon = null,
bool ShowOnHost = true,
bool ShowOnCommunity = true);
public sealed record FooterLinkDto(
string Label,
string Url);
public sealed record PublicSiteContentDto(
string HostDisplayName,
string HostTagline,
string NewsletterUrl,
string PrivacyEmail,
string PrivacyPolicyContent,
IEnumerable<PublicSocialLinkDto> SocialLinks,
IEnumerable<FooterLinkDto> FooterLinks);
public sealed record PublicSiteStatusResponse(
bool DemoLoginEnabled,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
public sealed record OverviewResponse(
int SeasonId,
int Year,
string Title,
DateOnly ShowDate,
TimeOnly ShowStartsAt,
string ShowStreamUrl,
string CurrentPhase,
bool IsCommunityOnly,
string LoginProvider,
IEnumerable<TimelineItem> Timeline,
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
IEnumerable<WinnerPreviewDto> WinnersPreview,
PublicSiteContentDto SiteContent,
IEnumerable<FaqItemDto> Faq);
@@ -0,0 +1,23 @@
namespace Backend.Contracts;
public sealed record CandidateSummaryDto(
int Id,
string DisplayName,
string ChannelSlug,
string Platform,
string? ClipUrl,
string? ClipTitle,
string? ClipPlatform);
public sealed record PublicCategoryDetailDto(
int Id,
string Name,
string GroupName,
string Description,
int MaxNomineesPerUser,
IEnumerable<CandidateSummaryDto> Candidates);
public sealed record SeasonCategoriesResponse(
int SeasonId,
int Year,
IEnumerable<PublicCategoryDetailDto> Categories);
@@ -0,0 +1,28 @@
namespace Backend.Contracts;
public sealed record UserNominationStateDto(
int CategoryId,
string[] Nominees);
public sealed record UserVoteStateDto(
int CategoryId,
int CandidateId);
public sealed record UserClipSubmissionStateDto(
int Id,
int? CategoryId,
string ClipUrl,
string Title,
string Creator,
string Platform,
string Status,
DateTimeOffset CreatedAt,
string? ReviewNote,
DateTimeOffset? ReviewedAt);
public sealed record UserParticipationResponse(
int SeasonId,
int Year,
UserNominationStateDto[] Nominations,
UserVoteStateDto[] Votes,
UserClipSubmissionStateDto[] ClipSubmissions);
@@ -0,0 +1,12 @@
namespace Backend.Contracts;
public sealed record WinnerArchiveItemDto(
string Category,
string WinnerName,
string WinnerSlug,
string WinnerPlatform,
string WinnerUrl);
public sealed record WinnerArchiveResponse(
int Year,
IEnumerable<WinnerArchiveItemDto> Items);
+30
View File
@@ -0,0 +1,30 @@
namespace Backend.Contracts;
public sealed record NominationEntryRequest(
string Name,
string StreamUrl);
public sealed record CreateNominationRequest(
int Year,
int CategoryId,
string TwitchUserId,
string[]? Nominees,
NominationEntryRequest[]? Nominations);
public sealed record VoteEntryRequest(
int CategoryId,
int CandidateId);
public sealed record CreateVoteRequest(
int SeasonId,
string TwitchUserId,
VoteEntryRequest[] Entries);
public sealed record CreateClipRequest(
int Year,
int? CategoryId,
int? CandidateId,
string TwitchUserId,
string ClipUrl,
string Title,
string Creator);
+37
View File
@@ -16,6 +16,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>(); public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>(); public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>(); public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -23,9 +24,29 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
{ {
entity.HasIndex(item => item.Year).IsUnique(); entity.HasIndex(item => item.Year).IsUnique();
entity.Property(item => item.Name).HasMaxLength(160); entity.Property(item => item.Name).HasMaxLength(160);
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
entity.Property(item => item.CurrentPhase).HasMaxLength(60); entity.Property(item => item.CurrentPhase).HasMaxLength(60);
}); });
modelBuilder.Entity<SiteSettings>(entity =>
{
entity.Property(item => item.HostDisplayName).HasMaxLength(120);
entity.Property(item => item.HostTagline).HasMaxLength(160);
entity.Property(item => item.NewsletterUrl).HasMaxLength(400);
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
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.DemoLoginEmail).HasMaxLength(180);
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
entity.Property(item => item.DemoLoginTwitchUserId).HasMaxLength(120);
entity.Property(item => item.DemoLoginDisplayName).HasMaxLength(120);
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
});
modelBuilder.Entity<Category>(entity => modelBuilder.Entity<Category>(entity =>
{ {
entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique(); entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique();
@@ -45,6 +66,11 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
{ {
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120); entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CandidateText).HasMaxLength(120); entity.Property(item => item.CandidateText).HasMaxLength(120);
entity.Property(item => item.StreamUrl).HasMaxLength(300);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
entity.HasIndex(item => new { item.SeasonId, item.Status });
}); });
modelBuilder.Entity<VoteBallot>(entity => modelBuilder.Entity<VoteBallot>(entity =>
@@ -55,6 +81,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
modelBuilder.Entity<AwardResult>(entity => modelBuilder.Entity<AwardResult>(entity =>
{ {
entity.HasIndex(item => new { item.SeasonId, item.CategoryId }).IsUnique();
entity.Property(item => item.CategoryName).HasMaxLength(120); entity.Property(item => item.CategoryName).HasMaxLength(120);
}); });
@@ -79,6 +106,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Summary).HasMaxLength(240); entity.Property(item => item.Summary).HasMaxLength(240);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80); entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400); entity.Property(item => item.UserAgent).HasMaxLength(400);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120); entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
}); });
@@ -89,6 +117,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.EntityType).HasMaxLength(80); entity.Property(item => item.EntityType).HasMaxLength(80);
entity.Property(item => item.EntityId).HasMaxLength(120); entity.Property(item => item.EntityId).HasMaxLength(120);
entity.Property(item => item.Summary).HasMaxLength(240); entity.Property(item => item.Summary).HasMaxLength(240);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400);
}); });
modelBuilder.Entity<ClipSubmission>(entity => modelBuilder.Entity<ClipSubmission>(entity =>
@@ -99,8 +129,15 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Creator).HasMaxLength(120); entity.Property(item => item.Creator).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40); entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.Status).HasMaxLength(20); entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80); entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.Status }); entity.HasIndex(item => new { item.SeasonId, item.Status });
entity.HasIndex(item => item.CandidateId);
entity.HasOne(item => item.Candidate)
.WithMany()
.HasForeignKey(item => item.CandidateId)
.OnDelete(DeleteBehavior.SetNull);
}); });
SeedData.Apply(modelBuilder); SeedData.Apply(modelBuilder);
+18 -2
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace Backend.Data; namespace Backend.Data;
@@ -7,9 +8,24 @@ public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<Awa
{ {
public AwardsDbContext CreateDbContext(string[] args) public AwardsDbContext CreateDbContext(string[] args)
{ {
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AwardsDbContext>(); var optionsBuilder = new DbContextOptionsBuilder<AwardsDbContext>();
var connectionString = Environment.GetEnvironmentVariable("VTSA_POSTGRES") var connectionString = configuration["VTSA_POSTGRES"]
?? "Host=localhost;Port=5432;Database=vtuber_star_awards;Username=postgres;Password=postgres"; ?? configuration.GetConnectionString("Postgres");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"No PostgreSQL connection string configured for design-time EF operations. " +
"Set VTSA_POSTGRES or ConnectionStrings__Postgres before running dotnet ef.");
}
optionsBuilder.UseNpgsql(connectionString); optionsBuilder.UseNpgsql(connectionString);
return new AwardsDbContext(optionsBuilder.Options); return new AwardsDbContext(optionsBuilder.Options);
@@ -13,6 +13,9 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "UserSessions" ALTER TABLE "UserSessions"
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT ''; ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
CREATE TABLE IF NOT EXISTS "RiskFlags" ( CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL, "SeasonId" integer NULL,
@@ -25,11 +28,15 @@ public static class OperationalTablesBootstrapper
"CreatedFromIp" character varying(80) NOT NULL, "CreatedFromIp" character varying(80) NOT NULL,
"UserAgent" character varying(400) NOT NULL, "UserAgent" character varying(400) NOT NULL,
"MetadataJson" text NOT NULL, "MetadataJson" text NOT NULL,
"ReviewNote" character varying(500) NULL,
"ReviewedByTwitchId" character varying(120) NULL, "ReviewedByTwitchId" character varying(120) NULL,
"CreatedAt" timestamp with time zone NOT NULL, "CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone NULL "ReviewedAt" timestamp with time zone NULL
); );
ALTER TABLE "RiskFlags"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt" CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt"
ON "RiskFlags" ("Status", "CreatedAt" DESC); ON "RiskFlags" ("Status", "CreatedAt" DESC);
@@ -44,9 +51,17 @@ public static class OperationalTablesBootstrapper
"EntityId" character varying(120) NOT NULL, "EntityId" character varying(120) NOT NULL,
"Summary" character varying(240) NOT NULL, "Summary" character varying(240) NOT NULL,
"MetadataJson" text NOT NULL, "MetadataJson" text NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL DEFAULT '',
"UserAgent" character varying(400) NOT NULL DEFAULT '',
"CreatedAt" timestamp with time zone NOT NULL "CreatedAt" timestamp with time zone NOT NULL
); );
ALTER TABLE "AdminAuditEntries"
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
ALTER TABLE "AdminAuditEntries"
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt" CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
ON "AdminAuditEntries" ("CreatedAt" DESC); ON "AdminAuditEntries" ("CreatedAt" DESC);
@@ -64,7 +79,54 @@ public static class OperationalTablesBootstrapper
"CreatedAt" timestamp with time zone NOT NULL "CreatedAt" timestamp with time zone NOT NULL
); );
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "CandidateId" integer NULL;
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status" CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
ON "ClipSubmissions" ("SeasonId", "Status"); ON "ClipSubmissions" ("SeasonId", "Status");
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
ON "ClipSubmissions" ("CandidateId");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId'
) THEN
ALTER TABLE "ClipSubmissions"
ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId"
FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id")
ON DELETE SET NULL;
END IF;
END $$;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
ON "Nominations" ("SeasonId", "Status");
"""); """);
} }
@@ -0,0 +1,145 @@
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
{
var seasonCategories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
foreach (var category in seasonCategories)
{
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
{
continue;
}
var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug);
category.GroupName = target.GroupName;
category.Name = target.Name;
category.Slug = target.Slug;
category.Description = target.Description;
category.SortOrder = target.SortOrder;
category.MaxNomineesPerUser = 3;
}
await db.SaveChangesAsync();
var existing = await db.Categories
.Where(item => item.SeasonId == season.Id)
.Select(item => item.Slug)
.ToArrayAsync();
var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var seed in SeedCatalog.CategorySeeds)
{
if (existingSlugs.Contains(seed.Slug))
{
continue;
}
db.Categories.Add(new Category
{
SeasonId = season.Id,
GroupName = seed.GroupName,
Name = seed.Name,
Slug = seed.Slug,
Description = seed.Description,
SortOrder = seed.SortOrder,
MaxNomineesPerUser = 3,
});
}
await db.SaveChangesAsync();
}
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var existing = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.Select(item => new { item.CategoryId, item.DisplayName, item.ChannelSlug })
.ToArrayAsync();
var existingKeys = existing
.Select(item => $"{item.CategoryId}|{item.DisplayName}|{item.ChannelSlug}".ToLowerInvariant())
.ToHashSet();
foreach (var seed in seeds)
{
if (!categories.TryGetValue(seed.CategorySlug, out var category))
{
continue;
}
var key = $"{category.Id}|{seed.DisplayName}|{seed.ChannelSlug}".ToLowerInvariant();
if (existingKeys.Contains(key))
{
continue;
}
db.Candidates.Add(new Candidate
{
SeasonId = season.Id,
CategoryId = category.Id,
DisplayName = seed.DisplayName,
ChannelSlug = seed.ChannelSlug,
Platform = seed.Platform,
});
}
await db.SaveChangesAsync();
}
private static async Task EnsureWinnersAsync(AwardsDbContext db, Season season, WinnerSeed[] seeds)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var candidates = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
var existingResults = await db.Results
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
foreach (var result in existingResults)
{
if (categories.Values.FirstOrDefault(item => item.Id == result.CategoryId) is { } category
&& result.CategoryName != category.Name)
{
result.CategoryName = category.Name;
}
}
var existingResultCategoryIds = existingResults.Select(item => item.CategoryId).ToHashSet();
foreach (var seed in seeds)
{
if (!categories.TryGetValue(seed.CategorySlug, out var category) || existingResultCategoryIds.Contains(category.Id))
{
continue;
}
var candidate = candidates.FirstOrDefault(item =>
item.CategoryId == category.Id
&& string.Equals(item.DisplayName, seed.DisplayName, StringComparison.OrdinalIgnoreCase));
if (candidate is null)
{
continue;
}
db.Results.Add(new AwardResult
{
SeasonId = season.Id,
CategoryId = category.Id,
CandidateId = candidate.Id,
CategoryName = category.Name,
});
}
}
}
+106
View File
@@ -0,0 +1,106 @@
namespace Backend.Data;
internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder);
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
internal sealed record SiteFaqSeed(string Question, string Answer);
internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon);
internal static class SeedCatalog
{
internal static readonly CategorySeed[] CategorySeeds =
[
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1),
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2),
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
new("Entertainment", "Best Variety", "best-variety", "Talk, Comedy, Watchalongs und kreative Streamformate.", 6),
new("Community", "Community Liebling", "community-liebling", "Creator:innen, die ihre Community besonders stark verbinden.", 7),
new("Collab", "Best Collab & Duo", "best-collab-duo", "Gemeinsame Streams, Projekte und Duo-Dynamik.", 8),
];
internal static readonly Dictionary<string, string> LegacyCategorySlugMap = new(StringComparer.OrdinalIgnoreCase)
{
["bestes-live-event"] = "best-newcomer",
["clip-des-jahres"] = "model-design",
["beste-community"] = "gesang-musik",
};
internal static readonly SiteFaqSeed[] SiteFaqSeeds =
[
new(
"Wer darf nominiert werden?",
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
new(
"Wie funktioniert das Voting?",
"Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."),
new(
"Was kostet die Teilnahme?",
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."),
new(
"Wann und wo findet die Award-Show statt?",
"Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"),
new(
"Ich wurde nominiert — was nun?",
"Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."),
];
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
[
new("Twitch", "twitch", "https://twitch.tv/jayuhime", "twitch"),
new("YouTube", "youtube", "https://youtube.com/c/Jayuhime", "youtube"),
new("X", "x", "https://x.com/jayuhime", "x"),
new("Instagram", "instagram", "https://instagram.com/jayuhime", "instagram"),
new("Discord", "discord", "https://discord.gg/jayuhime", "discord"),
];
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
[
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"),
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"),
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"),
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"),
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
new("best-gaming", "Kurainu", "@kurainu", "Twitch"),
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"),
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
new("community-liebling", "Lumi", "@lumi_vt", "Cake"),
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"),
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"),
];
internal static readonly WinnerSeed[] WinnerSeeds =
[
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"),
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"),
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"),
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"),
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"),
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"),
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"),
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"),
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"),
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"),
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"),
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"),
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"),
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"),
];
}
+71 -6
View File
@@ -1,5 +1,7 @@
using Backend.Domain; using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Backend.Data; namespace Backend.Data;
@@ -7,12 +9,68 @@ public static class SeedData
{ {
public static void Apply(ModelBuilder modelBuilder) public static void Apply(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<SiteSettings>().HasData(
new SiteSettings
{
Id = 1,
HostDisplayName = "Jayuhime",
HostTagline = "VTuber & Award Host",
NewsletterUrl = "https://vtuber-star-awards.de/newsletter",
PrivacyEmail = "datenschutz@vtuber-star-awards.de",
PrivacyPolicyContent = """
Verantwortliche:r
VTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de
Welche Daten wir verarbeiten
Bei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.
Für Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.
Rechtsgrundlage
Verarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.
Zweck der Verarbeitung
Durchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.
Löschfristen
Alle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.
Deine Rechte
Du hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.
Weitergabe an Dritte
Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.
""",
PrivacyPolicyUpdatedBy = "seed",
PrivacyPolicyUpdatedAt = new DateTimeOffset(2026, 6, 23, 0, 0, 0, TimeSpan.Zero),
ImprintUrl = "https://vtuber-star-awards.de/impressum",
ContactUrl = "https://vtuber-star-awards.de/kontakt",
SponsorsUrl = "https://vtuber-star-awards.de/partner",
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
SocialLinksJson = JsonSerializer.Serialize(new[]
{
new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true },
new { label = "YouTube", platform = "youtube", url = "https://youtube.com/c/Jayuhime", icon = "youtube", showOnHost = true, showOnCommunity = true },
new { label = "X", platform = "x", url = "https://x.com/jayuhime", icon = "x", showOnHost = true, showOnCommunity = true },
new { label = "Instagram", platform = "instagram", url = "https://instagram.com/jayuhime", icon = "instagram", showOnHost = true, showOnCommunity = true },
new { label = "Discord", platform = "discord", url = "https://discord.gg/jayuhime", icon = "discord", showOnHost = true, showOnCommunity = true },
}),
FaqJson = JsonSerializer.Serialize(new[]
{
new { question = "Wer darf nominiert werden?", answer = "Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhängig von Follower-Zahl oder Plattform. Die Community schlägt in der Nominierungsphase ihre Favorit:innen vor." },
new { question = "Wie funktioniert das Voting?", answer = "Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase." },
new { question = "Was kostet die Teilnahme?", answer = "Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans." },
new { question = "Wann und wo findet die Award-Show statt?", answer = "Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!" },
new { question = "Ich wurde nominiert — was nun?", answer = "Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt." },
}),
});
modelBuilder.Entity<Season>().HasData( modelBuilder.Entity<Season>().HasData(
new Season new Season
{ {
Id = 1, Id = 1,
Year = 2026, Year = 2026,
Name = "VTuber Star Awards 2026", Name = "VTuber Star Awards 2026",
ShowStreamUrl = "https://twitch.tv/jayuhime",
IsCurrent = true, IsCurrent = true,
IsCommunityOnly = true, IsCommunityOnly = true,
CurrentPhase = "Community Voting", CurrentPhase = "Community Voting",
@@ -23,12 +81,14 @@ public static class SeedData
ReviewStartsAt = new DateOnly(2026, 7, 1), ReviewStartsAt = new DateOnly(2026, 7, 1),
ReviewEndsAt = new DateOnly(2026, 7, 10), ReviewEndsAt = new DateOnly(2026, 7, 10),
ShowDate = new DateOnly(2026, 7, 20), ShowDate = new DateOnly(2026, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
}, },
new Season new Season
{ {
Id = 2, Id = 2,
Year = 2025, Year = 2025,
Name = "VTuber Star Awards 2025", Name = "VTuber Star Awards 2025",
ShowStreamUrl = "https://twitch.tv/jayuhime",
IsCurrent = false, IsCurrent = false,
IsCommunityOnly = true, IsCommunityOnly = true,
CurrentPhase = "Archived", CurrentPhase = "Archived",
@@ -39,12 +99,14 @@ public static class SeedData
ReviewStartsAt = new DateOnly(2025, 7, 1), ReviewStartsAt = new DateOnly(2025, 7, 1),
ReviewEndsAt = new DateOnly(2025, 7, 10), ReviewEndsAt = new DateOnly(2025, 7, 10),
ShowDate = new DateOnly(2025, 7, 20), ShowDate = new DateOnly(2025, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
}, },
new Season new Season
{ {
Id = 3, Id = 3,
Year = 2024, Year = 2024,
Name = "VTuber Star Awards 2024", Name = "VTuber Star Awards 2024",
ShowStreamUrl = "https://youtube.com/c/Jayuhime",
IsCurrent = false, IsCurrent = false,
IsCommunityOnly = true, IsCommunityOnly = true,
CurrentPhase = "Archived", CurrentPhase = "Archived",
@@ -55,12 +117,14 @@ public static class SeedData
ReviewStartsAt = new DateOnly(2024, 7, 1), ReviewStartsAt = new DateOnly(2024, 7, 1),
ReviewEndsAt = new DateOnly(2024, 7, 10), ReviewEndsAt = new DateOnly(2024, 7, 10),
ShowDate = new DateOnly(2024, 7, 20), ShowDate = new DateOnly(2024, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
}, },
new Season new Season
{ {
Id = 4, Id = 4,
Year = 2023, Year = 2023,
Name = "VTuber Star Awards 2023", Name = "VTuber Star Awards 2023",
ShowStreamUrl = "https://twitch.tv/jayuhime",
IsCurrent = false, IsCurrent = false,
IsCommunityOnly = true, IsCommunityOnly = true,
CurrentPhase = "Archived", CurrentPhase = "Archived",
@@ -71,6 +135,7 @@ public static class SeedData
ReviewStartsAt = new DateOnly(2023, 7, 1), ReviewStartsAt = new DateOnly(2023, 7, 1),
ReviewEndsAt = new DateOnly(2023, 7, 10), ReviewEndsAt = new DateOnly(2023, 7, 10),
ShowDate = new DateOnly(2023, 7, 20), ShowDate = new DateOnly(2023, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
}); });
modelBuilder.Entity<Category>().HasData( modelBuilder.Entity<Category>().HasData(
@@ -101,12 +166,12 @@ public static class SeedData
new Candidate { Id = 13, SeasonId = 4, CategoryId = 10, DisplayName = "Tenshi Vox", ChannelSlug = "@tenshivox", Platform = "Twitch" }); new Candidate { Id = 13, SeasonId = 4, CategoryId = 10, DisplayName = "Tenshi Vox", ChannelSlug = "@tenshivox", Platform = "Twitch" });
modelBuilder.Entity<AwardResult>().HasData( modelBuilder.Entity<AwardResult>().HasData(
new AwardResult { Id = 1, SeasonId = 2, CandidateId = 8, CategoryName = "VTuber des Jahres" }, new AwardResult { Id = 1, SeasonId = 2, CategoryId = 5, CandidateId = 8, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 2, SeasonId = 2, CandidateId = 9, CategoryName = "Bestes Live Event" }, new AwardResult { Id = 2, SeasonId = 2, CategoryId = 6, CandidateId = 9, CategoryName = "Bestes Live Event" },
new AwardResult { Id = 3, SeasonId = 2, CandidateId = 10, CategoryName = "Clip des Jahres" }, new AwardResult { Id = 3, SeasonId = 2, CategoryId = 7, CandidateId = 10, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 4, SeasonId = 3, CandidateId = 11, CategoryName = "VTuber des Jahres" }, new AwardResult { Id = 4, SeasonId = 3, CategoryId = 8, CandidateId = 11, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 5, SeasonId = 3, CandidateId = 12, CategoryName = "Clip des Jahres" }, new AwardResult { Id = 5, SeasonId = 3, CategoryId = 9, CandidateId = 12, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 6, SeasonId = 4, CandidateId = 13, CategoryName = "VTuber des Jahres" }); new AwardResult { Id = 6, SeasonId = 4, CategoryId = 10, CandidateId = 13, CategoryName = "VTuber des Jahres" });
modelBuilder.Entity<Nomination>().HasData( modelBuilder.Entity<Nomination>().HasData(
new Nomination { Id = 1, SeasonId = 1, CategoryId = 1, SubmittedByTwitchId = "twitch_hoshi", CandidateText = "Hoshimi Miyu", CreatedAt = new DateTimeOffset(2026, 6, 10, 13, 0, 0, TimeSpan.Zero) }, new Nomination { Id = 1, SeasonId = 1, CategoryId = 1, SubmittedByTwitchId = "twitch_hoshi", CandidateText = "Hoshimi Miyu", CreatedAt = new DateTimeOffset(2026, 6, 10, 13, 0, 0, TimeSpan.Zero) },
+42
View File
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
public static async Task EnsureAsync(AwardsDbContext db)
{
await EnsureSiteSettingsAsync(db);
var seasons = await db.Seasons.ToDictionaryAsync(item => item.Year);
if (seasons.Count == 0)
{
return;
}
foreach (var season in seasons.Values)
{
await EnsureCategoriesAsync(db, season);
}
if (seasons.TryGetValue(2026, out var currentSeason))
{
await EnsureCandidatesAsync(db, currentSeason, SeedCatalog.CurrentCandidateSeeds);
await EnsureSeedOperationalDataAsync(db, currentSeason);
}
foreach (var year in new[] { 2025, 2024, 2023 })
{
if (!seasons.TryGetValue(year, out var season))
{
continue;
}
var winners = SeedCatalog.WinnerSeeds.Where(item => item.Year == year).ToArray();
await EnsureCandidatesAsync(db, season, winners.Select(item => new CandidateSeed(item.CategorySlug, item.DisplayName, item.ChannelSlug, item.Platform)).ToArray());
await EnsureWinnersAsync(db, season, winners);
}
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,283 @@
using System.Text.Json;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureSeedOperationalDataAsync(AwardsDbContext db, Season season)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var candidates = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
{
db.ClipSubmissions.AddRange(
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"),
SubmittedByTwitchId = "local_user_3",
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
Title = "Starlight Debut Moment",
Creator = "Hoshimi Miyu",
Platform = "Twitch",
Status = "approved",
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 5, 0, TimeSpan.Zero),
},
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"),
SubmittedByTwitchId = "local_user_4",
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
Title = "Finale-Hype mit Chat-Chaos",
Creator = "Kurainu",
Platform = "Twitch",
Status = "approved",
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 10, 0, TimeSpan.Zero),
},
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "best-gaming"),
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"),
SubmittedByTwitchId = "local_user",
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
Title = "Epischer Clutch im Finale",
Creator = "Kurainu",
Platform = "Twitch",
Status = "pending",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 17, 9, 10, 0, TimeSpan.Zero),
},
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "gesang-musik"),
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"),
SubmittedByTwitchId = "local_user_2",
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
Title = "Live-Cover mit Gänsehaut",
Creator = "Melo Diva",
Platform = "YouTube",
Status = "approved",
ReviewNote = "Geprüfter Clip fuer Review-Workflow.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 0, 0, TimeSpan.Zero),
});
}
if (!normalizedLegacyState.HasRiskSeed && !await db.RiskFlags.AnyAsync(item => item.Source == "seed"))
{
db.RiskFlags.Add(new RiskFlag
{
SeasonId = season.Id,
TwitchUserId = "sample_user",
Source = "seed",
Type = "rapid_vote_updates",
Severity = "medium",
Status = "open",
Summary = "Mehrere Voting-Aenderungen in kurzer Zeit erkannt.",
CreatedFromIp = "127.0.0.1",
UserAgent = "seed-bootstrap",
MetadataJson = JsonSerializer.Serialize(new { recentVoteSubmissions = 3 }),
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 40, 0, TimeSpan.Zero),
});
}
if (!normalizedLegacyState.HasAuditSeed && !await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"))
{
db.AdminAuditEntries.Add(new AdminAuditEntry
{
AdminTwitchUserId = "system",
ActionType = "seed.initialize",
EntityType = "database",
EntityId = season.Year.ToString(),
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }),
CreatedFromIp = "seed",
UserAgent = "seed-bootstrap",
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
});
}
}
private static int? ResolveCategoryId(IReadOnlyDictionary<string, Category> categories, string slug) =>
categories.TryGetValue(slug, out var category) ? category.Id : null;
private static int? ResolveCandidateId(
IReadOnlyDictionary<string, Category> categories,
IEnumerable<Candidate> candidates,
string categorySlug,
string displayName)
{
var categoryId = ResolveCategoryId(categories, categorySlug);
return categoryId is int resolvedCategoryId
? candidates.FirstOrDefault(item =>
item.CategoryId == resolvedCategoryId
&& string.Equals(item.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))?.Id
: null;
}
private static async Task<LegacySeedState> NormalizeLegacyDemoLabelsAsync(AwardsDbContext db)
{
var legacySessions = await db.UserSessions
.Where(item => item.TwitchUserId == "admin_demo" || item.TwitchUserId == "jayuhime_demo" || item.TwitchUserId == "demo_user")
.ToArrayAsync();
foreach (var session in legacySessions)
{
session.TwitchUserId = session.TwitchUserId switch
{
"admin_demo" => "jayuhime_admin",
"jayuhime_demo" => "jayuhime_viewer",
"demo_user" => "local_user",
_ => session.TwitchUserId,
};
session.DisplayName = session.DisplayName switch
{
"Admin Demo" => "Jayuhime Admin",
"Demo User" => "Local User",
_ => session.DisplayName,
};
}
var legacyClipSubmissions = await db.ClipSubmissions
.Where(item =>
item.SubmittedByTwitchId == "demo_user" ||
item.SubmittedByTwitchId == "demo_user_2" ||
item.ClipUrl.Contains("Demo") ||
item.ClipUrl.Contains("demo") ||
(item.ReviewNote != null && item.ReviewNote.Contains("Demo-Clip")))
.ToArrayAsync();
foreach (var clip in legacyClipSubmissions)
{
clip.SubmittedByTwitchId = clip.SubmittedByTwitchId switch
{
"demo_user" => "local_user",
"demo_user_2" => "local_user_2",
_ => clip.SubmittedByTwitchId,
};
clip.ClipUrl = clip.ClipUrl
.Replace("DemoGamingMoment", "EpicGamingMoment")
.Replace("demoSong", "liveCoverMoment");
clip.ReviewNote = clip.ReviewNote?.Replace("Demo-Clip", "Geprüfter Clip");
}
await LinkExistingClipsToCandidatesAsync(db);
var legacyRiskFlags = await db.RiskFlags
.Where(item =>
item.Source == "demo" ||
item.Summary.StartsWith("Demo:") ||
item.TwitchUserId == "jayuhime_demo" ||
item.TwitchUserId == "demo_user")
.ToArrayAsync();
foreach (var flag in legacyRiskFlags)
{
flag.Source = "seed";
flag.TwitchUserId = flag.TwitchUserId switch
{
"demo_user" => "local_user",
"jayuhime_demo" => "jayuhime_viewer",
_ => flag.TwitchUserId,
};
flag.Summary = flag.Summary.Replace("Demo: ", string.Empty);
flag.UserAgent = flag.UserAgent == "demo-seed" ? "seed-bootstrap" : flag.UserAgent;
}
var legacyAuditEntries = await db.AdminAuditEntries
.Where(item =>
item.ActionType == "demo.seed" ||
item.Summary.Contains("Demo-Inhalte") ||
item.AdminTwitchUserId == "admin_demo" ||
item.AdminTwitchUserId == "jayuhime_demo")
.ToArrayAsync();
foreach (var entry in legacyAuditEntries)
{
entry.AdminTwitchUserId = entry.AdminTwitchUserId switch
{
"admin_demo" => "jayuhime_admin",
"jayuhime_demo" => "jayuhime_viewer",
_ => entry.AdminTwitchUserId,
};
if (entry.ActionType == "demo.seed")
{
entry.ActionType = "seed.initialize";
}
if (entry.Summary.Contains("Demo-Inhalte"))
{
entry.Summary = "Startinhalte wurden in der Datenbank bereitgestellt.";
}
}
return new LegacySeedState(
legacyRiskFlags.Length > 0 || await db.RiskFlags.AnyAsync(item => item.Source == "seed"),
legacyAuditEntries.Length > 0 || await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"));
}
private static async Task LinkExistingClipsToCandidatesAsync(AwardsDbContext db)
{
var clips = await db.ClipSubmissions
.Where(item => item.CandidateId == null && item.CategoryId != null && item.Creator != string.Empty)
.ToArrayAsync();
if (clips.Length == 0)
{
return;
}
var seasonIds = clips.Select(item => item.SeasonId).Distinct().ToArray();
var categoryIds = clips.Select(item => item.CategoryId!.Value).Distinct().ToArray();
var candidates = await db.Candidates
.Where(item => seasonIds.Contains(item.SeasonId) && categoryIds.Contains(item.CategoryId))
.ToArrayAsync();
foreach (var clip in clips)
{
var creatorKey = NormalizeSeedCandidateKey(clip.Creator);
var candidate = candidates.FirstOrDefault(item =>
item.SeasonId == clip.SeasonId
&& item.CategoryId == clip.CategoryId
&& (NormalizeSeedCandidateKey(item.DisplayName) == creatorKey
|| NormalizeSeedCandidateKey(item.ChannelSlug) == creatorKey));
if (candidate is not null)
{
clip.CandidateId = candidate.Id;
}
}
}
private static string NormalizeSeedCandidateKey(string value) =>
new(
value
.Trim()
.TrimStart('@')
.ToLowerInvariant()
.Where(char.IsLetterOrDigit)
.ToArray());
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
}
@@ -0,0 +1,75 @@
using System.Text.Json;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureSiteSettingsAsync(AwardsDbContext db)
{
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return;
}
if (!HasValidSiteArray(settings.FaqJson, "question", "answer"))
{
settings.FaqJson = JsonSerializer.Serialize(SeedCatalog.SiteFaqSeeds.Select(item => new
{
question = item.Question,
answer = item.Answer,
}));
}
if (!HasValidSiteArray(settings.SocialLinksJson, "label", "platform", "url"))
{
settings.SocialLinksJson = JsonSerializer.Serialize(SeedCatalog.SiteSocialSeeds.Select(item => new
{
label = item.Label,
platform = item.Platform,
url = item.Url,
icon = item.Icon,
showOnHost = true,
showOnCommunity = true,
}));
}
if (!HasValidRiskRules(settings.RiskRulesJson))
{
settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults);
}
}
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
{
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return false;
}
return document.RootElement.EnumerateArray().Any(item =>
item.ValueKind == JsonValueKind.Object
&& requiredKeys.All(key =>
item.TryGetProperty(key, out var value)
&& value.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(value.GetString())));
}
catch (JsonException)
{
return false;
}
}
private static bool HasValidRiskRules(string? json) =>
RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length;
}
+2
View File
@@ -9,5 +9,7 @@ public sealed class AdminAuditEntry
public string EntityId { get; set; } = string.Empty; public string EntityId { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty;
public string MetadataJson { get; set; } = "{}"; public string MetadataJson { get; set; } = "{}";
public string CreatedFromIp { get; set; } = string.Empty;
public string UserAgent { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
} }
+2
View File
@@ -5,6 +5,8 @@ public sealed class AwardResult
public int Id { get; set; } public int Id { get; set; }
public int SeasonId { get; set; } public int SeasonId { get; set; }
public Season Season { get; set; } = null!; public Season Season { get; set; } = null!;
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
public int CandidateId { get; set; } public int CandidateId { get; set; }
public Candidate Candidate { get; set; } = null!; public Candidate Candidate { get; set; } = null!;
public string CategoryName { get; set; } = string.Empty; public string CategoryName { get; set; } = string.Empty;
+5
View File
@@ -5,12 +5,17 @@ public sealed class ClipSubmission
public int Id { get; set; } public int Id { get; set; }
public int SeasonId { get; set; } public int SeasonId { get; set; }
public int? CategoryId { get; set; } public int? CategoryId { get; set; }
public int? CandidateId { get; set; }
public Candidate? Candidate { get; set; }
public string SubmittedByTwitchId { get; set; } = string.Empty; public string SubmittedByTwitchId { get; set; } = string.Empty;
public string ClipUrl { get; set; } = string.Empty; public string ClipUrl { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Creator { get; set; } = string.Empty; public string Creator { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty; public string Platform { get; set; } = string.Empty;
public string Status { get; set; } = "pending"; 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 CreatedFromIp { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
} }
+5
View File
@@ -11,5 +11,10 @@ public sealed class Nomination
public int? CandidateId { get; set; } public int? CandidateId { get; set; }
public Candidate? Candidate { get; set; } public Candidate? Candidate { get; set; }
public string? CandidateText { get; set; } public string? CandidateText { get; set; }
public string? StreamUrl { get; set; }
public string Status { get; set; } = "pending";
public string? ReviewNote { get; set; }
public string? ReviewedByTwitchId { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
} }
+1
View File
@@ -14,6 +14,7 @@ public sealed class RiskFlag
public string CreatedFromIp { get; set; } = string.Empty; public string CreatedFromIp { get; set; } = string.Empty;
public string UserAgent { get; set; } = string.Empty; public string UserAgent { get; set; } = string.Empty;
public string MetadataJson { get; set; } = "{}"; public string MetadataJson { get; set; } = "{}";
public string? ReviewNote { get; set; }
public string? ReviewedByTwitchId { get; set; } public string? ReviewedByTwitchId { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; } public DateTimeOffset? ReviewedAt { get; set; }
+2
View File
@@ -5,6 +5,7 @@ public sealed class Season
public int Id { get; set; } public int Id { get; set; }
public int Year { get; set; } public int Year { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string ShowStreamUrl { get; set; } = string.Empty;
public bool IsCurrent { get; set; } public bool IsCurrent { get; set; }
public bool IsCommunityOnly { get; set; } public bool IsCommunityOnly { get; set; }
public string CurrentPhase { get; set; } = string.Empty; public string CurrentPhase { get; set; } = string.Empty;
@@ -15,6 +16,7 @@ public sealed class Season
public DateOnly ReviewStartsAt { get; set; } public DateOnly ReviewStartsAt { get; set; }
public DateOnly ReviewEndsAt { get; set; } public DateOnly ReviewEndsAt { get; set; }
public DateOnly ShowDate { get; set; } public DateOnly ShowDate { get; set; }
public TimeOnly ShowStartsAt { get; set; } = new(20, 0);
public ICollection<Category> Categories { get; set; } = []; public ICollection<Category> Categories { get; set; } = [];
public ICollection<AwardResult> Results { get; set; } = []; public ICollection<AwardResult> Results { get; set; } = [];
} }
+29
View File
@@ -0,0 +1,29 @@
namespace Backend.Domain;
public sealed class SiteSettings
{
public int Id { get; set; }
public string HostDisplayName { get; set; } = string.Empty;
public string HostTagline { get; set; } = string.Empty;
public string NewsletterUrl { get; set; } = string.Empty;
public string PrivacyEmail { get; set; } = string.Empty;
public string PrivacyPolicyContent { get; set; } = string.Empty;
public string? PrivacyPolicyUpdatedBy { get; set; }
public DateTimeOffset? PrivacyPolicyUpdatedAt { get; set; }
public string ImprintUrl { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
public string SocialLinksJson { get; set; } = "[]";
public string FaqJson { get; set; } = "[]";
public string RiskRulesJson { get; set; } = "[]";
public bool DemoLoginManagedByDatabase { get; set; }
public bool DemoLoginEnabled { get; set; }
public string DemoLoginEmail { get; set; } = string.Empty;
public string DemoLoginPasswordHash { get; set; } = string.Empty;
public string DemoLoginPasswordSalt { get; set; } = string.Empty;
public string DemoLoginTwitchUserId { get; set; } = "jayuhime_admin";
public string DemoLoginDisplayName { get; set; } = "Jayuhime Admin";
public bool MaintenanceModeEnabled { get; set; }
public string MaintenanceTitle { get; set; } = "Sternenpause";
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
}
@@ -0,0 +1,86 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
private static async Task<IResult> DeleteClip(
HttpContext context,
int clipId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
if (clip is null)
{
return Results.NotFound();
}
db.ClipSubmissions.Remove(clip);
adminAuditService.AddEntry(
session.TwitchUserId,
"clip.delete",
"clip",
clip.Id.ToString(),
$"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.",
new { clip.Platform },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, clipId });
}
private static async Task<IResult> UpdateClipStatus(
HttpContext context,
int clipId,
UpdateClipStatusRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
if (clip is null)
{
return Results.NotFound();
}
var normalizedStatus = string.IsNullOrWhiteSpace(request.Status)
? "pending"
: request.Status.Trim().ToLowerInvariant();
if (normalizedStatus is not ("pending" or "approved" or "rejected"))
{
return Results.BadRequest(new { message = "Clip status must be pending, approved or rejected." });
}
clip.Status = normalizedStatus;
clip.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
if (normalizedStatus == "pending")
{
clip.ReviewedAt = null;
clip.ReviewedByTwitchId = null;
}
else
{
clip.ReviewedAt = DateTimeOffset.UtcNow;
clip.ReviewedByTwitchId = session.TwitchUserId;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"clip.status.update",
"clip",
clip.Id.ToString(),
$"Clip-Einreichung {clip.Id} wurde auf {clip.Status} gesetzt.",
new { clip.Status, clip.ReviewNote },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, clipId = clip.Id, status = clip.Status });
}
}
@@ -0,0 +1,221 @@
using Backend.Contracts;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminDashboardEndpoints
{
public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/dashboard", GetDashboard).WithName("GetAdminDashboard").WithOpenApi();
group.MapGet("/audit-entries", GetAuditEntries).WithName("GetAdminAuditEntries").WithOpenApi();
return group;
}
private static async Task<IResult> GetDashboard(AwardsDbContext db)
{
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
if (currentSeason is null)
{
return Results.NotFound();
}
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id);
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id);
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null);
var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
var topCategoryNames = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Ballot.SeasonId == currentSeason.Id)
.Select(item => item.Category.Name)
.ToListAsync();
var topCategories = topCategoryNames
.GroupBy(name => name)
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count()))
.OrderByDescending(item => item.Votes)
.Take(5)
.ToArray();
var riskFlags = await db.RiskFlags
.AsNoTracking()
.Where(item => item.Status == "open")
.OrderByDescending(item => item.CreatedAt)
.Take(8)
.ToArrayAsync();
var riskFlagDtos = riskFlags.Select(AdminRiskFlagMappings.ToDto).ToArray();
var auditEntries = await db.AdminAuditEntries
.AsNoTracking()
.OrderByDescending(item => item.CreatedAt)
.Take(8)
.Select(item => new AdminAuditEntryDto(
item.Id,
item.AdminTwitchUserId,
item.ActionType,
item.EntityType,
item.EntityId,
item.Summary,
item.CreatedAt,
item.MetadataJson,
item.CreatedFromIp,
item.UserAgent))
.ToArrayAsync();
var activityItems = auditEntries
.Take(3)
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
.ToArray();
return Results.Ok(new AdminDashboardResponse(
new[]
{
new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"),
new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Freitext-Nominierungen mit Review-Bedarf"),
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
},
activityItems,
topCategories,
riskFlagDtos,
auditEntries));
}
private static async Task<IResult> GetAuditEntries(
int? limit,
string? query,
string? admin,
string? action,
string? entityType,
DateTimeOffset? from,
DateTimeOffset? to,
string? cursor,
AwardsDbContext db)
{
var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500);
var search = query?.Trim();
var auditQuery = db.AdminAuditEntries.AsNoTracking();
if (!TryDecodeAuditCursor(cursor, out var decodedCursor))
{
return Results.BadRequest(new { message = "Invalid audit cursor." });
}
if (!string.IsNullOrWhiteSpace(search))
{
var pattern = $"%{search}%";
auditQuery = auditQuery.Where(item =>
EF.Functions.ILike(item.AdminTwitchUserId, pattern) ||
EF.Functions.ILike(item.ActionType, pattern) ||
EF.Functions.ILike(item.EntityType, pattern) ||
EF.Functions.ILike(item.EntityId, pattern) ||
EF.Functions.ILike(item.Summary, pattern) ||
EF.Functions.ILike(item.MetadataJson, pattern) ||
EF.Functions.ILike(item.CreatedFromIp, pattern) ||
EF.Functions.ILike(item.UserAgent, pattern));
}
if (!string.IsNullOrWhiteSpace(admin))
{
var normalizedAdmin = admin.Trim();
auditQuery = auditQuery.Where(item => item.AdminTwitchUserId == normalizedAdmin);
}
if (!string.IsNullOrWhiteSpace(action))
{
var normalizedAction = action.Trim();
auditQuery = auditQuery.Where(item => item.ActionType == normalizedAction);
}
if (!string.IsNullOrWhiteSpace(entityType))
{
var normalizedEntityType = entityType.Trim();
auditQuery = auditQuery.Where(item => item.EntityType == normalizedEntityType);
}
if (from.HasValue)
{
auditQuery = auditQuery.Where(item => item.CreatedAt >= from.Value);
}
if (to.HasValue)
{
auditQuery = auditQuery.Where(item => item.CreatedAt <= to.Value);
}
var totalCount = await auditQuery.CountAsync();
if (decodedCursor is not null)
{
auditQuery = auditQuery.Where(item =>
item.CreatedAt < decodedCursor.CreatedAt ||
(item.CreatedAt == decodedCursor.CreatedAt && item.Id < decodedCursor.Id));
}
var page = await auditQuery
.OrderByDescending(item => item.CreatedAt)
.ThenByDescending(item => item.Id)
.Take(normalizedLimit + 1)
.Select(item => new AdminAuditEntryDto(
item.Id,
item.AdminTwitchUserId,
item.ActionType,
item.EntityType,
item.EntityId,
item.Summary,
item.CreatedAt,
item.MetadataJson,
item.CreatedFromIp,
item.UserAgent))
.ToArrayAsync();
var hasMore = page.Length > normalizedLimit;
var entries = page.Take(normalizedLimit).ToArray();
var nextCursor = hasMore && entries.Length > 0
? EncodeAuditCursor(entries[^1])
: null;
return Results.Ok(new AdminAuditEntriesResponse(
entries,
totalCount,
entries.Length,
nextCursor,
normalizedLimit));
}
private static string EncodeAuditCursor(AdminAuditEntryDto entry) =>
$"{entry.CreatedAt.UtcTicks}:{entry.Id}";
private static bool TryDecodeAuditCursor(string? cursor, out AuditCursor? decodedCursor)
{
decodedCursor = null;
if (string.IsNullOrWhiteSpace(cursor))
{
return true;
}
var parts = cursor.Split(':', 2);
if (parts.Length != 2 ||
!long.TryParse(parts[0], out var ticks) ||
!int.TryParse(parts[1], out var id))
{
return false;
}
try
{
decodedCursor = new AuditCursor(new DateTimeOffset(ticks, TimeSpan.Zero), id);
return true;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
private sealed record AuditCursor(DateTimeOffset CreatedAt, int Id);
}
@@ -0,0 +1,10 @@
using Backend.Domain;
using Backend.Security;
namespace Backend.Endpoints;
internal static class AdminEndpointConventions
{
public static UserSession CurrentSession(HttpContext context) =>
context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context.");
}
+36
View File
@@ -0,0 +1,36 @@
using Backend.Security;
namespace Backend.Endpoints;
public static class AdminEndpoints
{
public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin")
.AddEndpointFilter<AdminSessionFilter>();
group.MapAdminSiteSettingsEndpoints();
var managerGroup = group.MapGroup(string.Empty)
.AddEndpointFilter(RequireAdminWorkspaceRole);
managerGroup.MapAdminDashboardEndpoints();
managerGroup.MapAdminSeasonManagementEndpoints();
managerGroup.MapAdminModerationEndpoints();
return app;
}
private static async ValueTask<object?> RequireAdminWorkspaceRole(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var session = AdminEndpointConventions.CurrentSession(context.HttpContext);
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
{
return Results.Json(new { message = "This admin area requires an admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
}
return await next(context);
}
}
@@ -0,0 +1,18 @@
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group)
{
group.MapDelete("/clips/{clipId:int}", DeleteClip).WithName("DeleteAdminClip").WithOpenApi();
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus).WithName("UpdateAdminClipStatus").WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination).WithName("ApproveAdminNomination").WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination).WithName("RejectAdminNomination").WithOpenApi();
group.MapGet("/risk-flags", GetRiskFlags).WithName("GetAdminRiskFlags").WithOpenApi();
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag).WithName("ResolveRiskFlag").WithOpenApi();
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags).WithName("BulkResolveRiskFlags").WithOpenApi();
group.MapGet("/risk-rules", GetRiskRules).WithName("GetAdminRiskRules").WithOpenApi();
group.MapPut("/risk-rules", UpdateRiskRules).WithName("UpdateAdminRiskRules").WithOpenApi();
return group;
}
}
@@ -0,0 +1,125 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
private static async Task<IResult> ApproveNomination(
HttpContext context,
int nominationId,
ApproveNominationRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations
.Include(item => item.Category)
.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
? nomination.CandidateText
: request.DisplayName.Trim();
if (string.IsNullOrWhiteSpace(rawDisplayName))
{
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
}
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryId == nomination.CategoryId
&& item.DisplayName.ToLower() == rawDisplayName.ToLower());
var candidate = existingCandidate;
if (candidate is null)
{
candidate = new Candidate
{
SeasonId = nomination.SeasonId,
CategoryId = nomination.CategoryId,
DisplayName = rawDisplayName,
ChannelSlug = channelSlug,
Platform = platform,
};
db.Candidates.Add(candidate);
await db.SaveChangesAsync(context.RequestAborted);
}
else
{
if (!string.IsNullOrWhiteSpace(channelSlug))
{
candidate.ChannelSlug = channelSlug;
}
if (!string.IsNullOrWhiteSpace(platform))
{
candidate.Platform = platform;
}
}
nomination.CandidateId = candidate.Id;
nomination.Status = "approved";
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
nomination.ReviewedAt = DateTimeOffset.UtcNow;
nomination.ReviewedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.approve",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
}
private static async Task<IResult> RejectNomination(
HttpContext context,
int nominationId,
RejectNominationRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
nomination.CandidateId = null;
nomination.Status = "rejected";
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
nomination.ReviewedAt = DateTimeOffset.UtcNow;
nomination.ReviewedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.reject",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde verworfen.",
new { nomination.ReviewNote },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
}
}
+382
View File
@@ -0,0 +1,382 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
private static async Task<IResult> GetRiskFlags(
int? limit,
int? offset,
string? status,
string? severity,
string? query,
bool? reviewedOnly,
AwardsDbContext db)
{
var normalizedLimit = Math.Clamp(limit ?? 25, 1, 100);
var normalizedOffset = Math.Max(offset ?? 0, 0);
var normalizedStatus = NormalizeRiskFlagStatus(status, allowAll: true);
if (normalizedStatus is null)
{
return Results.BadRequest(new { message = "Risk flag status must be open, resolved, dismissed or all." });
}
var riskQuery = db.RiskFlags.AsNoTracking();
if (reviewedOnly == true)
{
riskQuery = riskQuery.Where(item => item.Status != "open");
}
if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase))
{
var normalizedSeverity = severity.Trim().ToLowerInvariant();
if (normalizedSeverity is not ("low" or "medium" or "high"))
{
return Results.BadRequest(new { message = "Risk flag severity must be low, medium, high or all." });
}
riskQuery = riskQuery.Where(item => item.Severity == normalizedSeverity);
}
if (normalizedStatus != "all")
{
riskQuery = riskQuery.Where(item => item.Status == normalizedStatus);
}
if (!string.IsNullOrWhiteSpace(query))
{
var pattern = $"%{query.Trim()}%";
riskQuery = riskQuery.Where(item =>
EF.Functions.ILike(item.Source, pattern) ||
EF.Functions.ILike(item.Type, pattern) ||
EF.Functions.ILike(item.Severity, pattern) ||
EF.Functions.ILike(item.Status, pattern) ||
EF.Functions.ILike(item.Summary, pattern) ||
(item.TwitchUserId != null && EF.Functions.ILike(item.TwitchUserId, pattern)) ||
EF.Functions.ILike(item.CreatedFromIp, pattern) ||
(item.ReviewNote != null && EF.Functions.ILike(item.ReviewNote, pattern)) ||
EF.Functions.ILike(item.MetadataJson, pattern));
}
var totalCount = await riskQuery.CountAsync();
var severityCounts = await riskQuery
.GroupBy(item => item.Severity)
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
.ToArrayAsync();
var statusCounts = await riskQuery
.GroupBy(item => item.Status)
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
.ToArrayAsync();
var flags = await riskQuery
.OrderByDescending(item => item.CreatedAt)
.Skip(normalizedOffset)
.Take(normalizedLimit)
.ToArrayAsync();
var items = flags.Select(AdminRiskFlagMappings.ToDto).ToArray();
return Results.Ok(new AdminRiskFlagsResponse(
items,
totalCount,
items.Length,
normalizedOffset,
normalizedLimit,
normalizedOffset + items.Length < totalCount,
severityCounts,
statusCounts));
}
private static async Task<IResult> ResolveRiskFlag(
HttpContext context,
int riskFlagId,
ResolveRiskFlagRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
if (riskFlag is null)
{
return Results.NotFound();
}
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
if (normalizedStatus is null)
{
return Results.BadRequest(new { message = "Risk flag status must be open, resolved or dismissed." });
}
var previousStatus = riskFlag.Status;
var previousReviewNote = riskFlag.ReviewNote;
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
riskFlag.Status = normalizedStatus;
if (normalizedStatus == "open")
{
riskFlag.ReviewedAt = null;
riskFlag.ReviewedByTwitchId = null;
riskFlag.ReviewNote = null;
}
else
{
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
riskFlag.ReviewNote = normalizedReviewNote;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"risk.resolve",
"risk-flag",
riskFlag.Id.ToString(),
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
new
{
riskFlag.Type,
riskFlag.Source,
changes = BuildRiskResolutionChanges(previousStatus, riskFlag.Status, previousReviewNote, riskFlag.ReviewNote),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
}
private static async Task<IResult> BulkResolveRiskFlags(
HttpContext context,
BulkResolveRiskFlagsRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var normalizedIds = request.RiskFlagIds
.Distinct()
.Take(100)
.ToArray();
if (normalizedIds.Length == 0)
{
return Results.BadRequest(new { message = "Bitte waehle mindestens einen Risikohinweis aus." });
}
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
if (normalizedStatus is null || normalizedStatus == "open")
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung unterstuetzt erledigt oder verworfen." });
}
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
if (string.IsNullOrWhiteSpace(normalizedReviewNote))
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht eine Review-Notiz." });
}
var riskFlags = await db.RiskFlags
.Where(item => normalizedIds.Contains(item.Id))
.ToArrayAsync(context.RequestAborted);
if (riskFlags.Length != normalizedIds.Length)
{
return Results.BadRequest(new { message = "Mindestens ein Risikohinweis wurde nicht gefunden." });
}
if (riskFlags.Any(item => item.Severity != "low"))
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer Low-Severity-Hinweise erlaubt." });
}
if (riskFlags.Any(item => item.Status != "open"))
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Hinweise erlaubt." });
}
var reviewedAt = DateTimeOffset.UtcNow;
foreach (var riskFlag in riskFlags)
{
riskFlag.Status = normalizedStatus;
riskFlag.ReviewNote = normalizedReviewNote;
riskFlag.ReviewedAt = reviewedAt;
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"risk.bulk-resolve",
"risk-flag",
string.Join(",", normalizedIds),
$"{normalizedIds.Length} Low-Risk Flags wurden als {normalizedStatus} markiert.",
new
{
status = normalizedStatus,
count = normalizedIds.Length,
riskFlagIds = normalizedIds,
changes = new[]
{
new
{
field = "status",
label = "Status",
from = "open",
to = normalizedStatus,
sensitive = false,
},
new
{
field = "reviewNote",
label = "Review-Notiz",
from = "keine Notiz",
to = "Notiz vorhanden",
sensitive = true,
},
},
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, count = normalizedIds.Length, status = normalizedStatus });
}
private static async Task<IResult> GetRiskRules(AwardsDbContext db)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(new AdminRiskRulesResponse(RiskRuleSettings.Read(settings).Select(ToRiskRuleDto).ToArray()));
}
private static async Task<IResult> UpdateRiskRules(
HttpContext context,
UpdateRiskRulesRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
{
return Results.Json(new { message = "Risk-Regeln koennen nur Admins oder Owner aendern." }, statusCode: StatusCodes.Status403Forbidden);
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var before = RiskRuleSettings.Read(settings);
var mergedRules = RiskRuleSettings.Defaults
.Select(defaultRule =>
{
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
return requestRule is null
? defaultRule
: new RiskRuleSetting(
defaultRule.Key,
defaultRule.Label,
requestRule.Enabled,
requestRule.Threshold,
requestRule.WindowMinutes,
requestRule.Severity,
defaultRule.Description);
})
.ToArray();
settings.RiskRulesJson = RiskRuleSettings.Serialize(mergedRules);
var after = RiskRuleSettings.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.Threshold}/{previous.WindowMinutes}/{previous.Severity}",
to = $"{rule.Enabled}/{rule.Threshold}/{rule.WindowMinutes}/{rule.Severity}",
sensitive = false,
};
})
.Where(change => change.from != change.to)
.ToArray();
adminAuditService.AddEntry(
session.TwitchUserId,
"risk-rules.update",
"site-settings",
settings.Id.ToString(),
"Risk-Regeln wurden aktualisiert.",
new { changes },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new AdminRiskRulesResponse(after.Select(ToRiskRuleDto).ToArray()));
}
private static AdminRiskRuleDto ToRiskRuleDto(RiskRuleSetting rule) =>
new(rule.Key, rule.Label, rule.Enabled, rule.Threshold, rule.WindowMinutes, rule.Severity, rule.Description);
private static string? NormalizeReviewNote(string? reviewNote)
{
if (string.IsNullOrWhiteSpace(reviewNote))
{
return null;
}
var trimmedReviewNote = reviewNote.Trim();
return trimmedReviewNote.Length <= 500 ? trimmedReviewNote : trimmedReviewNote[..500];
}
private static object[] BuildRiskResolutionChanges(
string previousStatus,
string currentStatus,
string? previousReviewNote,
string? currentReviewNote)
{
var changes = new List<object>
{
new
{
field = "status",
label = "Status",
from = previousStatus,
to = currentStatus,
sensitive = false,
},
};
if (!string.Equals(previousReviewNote, currentReviewNote, StringComparison.Ordinal))
{
changes.Add(new
{
field = "reviewNote",
label = "Review-Notiz",
from = string.IsNullOrWhiteSpace(previousReviewNote) ? "keine Notiz" : "Notiz vorhanden",
to = string.IsNullOrWhiteSpace(currentReviewNote) ? "keine Notiz" : "Notiz vorhanden",
sensitive = true,
});
}
return changes.ToArray();
}
private static string? NormalizeRiskFlagStatus(string? status, bool allowAll)
{
var normalizedStatus = string.IsNullOrWhiteSpace(status) ? "open" : status.Trim().ToLowerInvariant();
return normalizedStatus switch
{
"open" or "resolved" or "dismissed" => normalizedStatus,
"all" when allowAll => normalizedStatus,
_ => null,
};
}
}
+124
View File
@@ -0,0 +1,124 @@
using System.Text.Json;
using Backend.Contracts;
using Backend.Domain;
namespace Backend.Endpoints;
public static class AdminRiskFlagMappings
{
public static AdminRiskFlagDto ToDto(RiskFlag item) =>
new(
item.Id,
item.Source,
item.Type,
item.Severity,
item.Status,
item.Summary,
item.TwitchUserId,
item.CreatedFromIp,
item.CreatedAt,
item.MetadataJson,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt,
BuildEntityLinks(item));
public static AdminRiskEntityLinkDto[] BuildEntityLinks(RiskFlag item)
{
var links = ReadExplicitLinks(item.MetadataJson).ToList();
if (links.Count > 0)
{
return links.ToArray();
}
var query = Uri.EscapeDataString(item.TwitchUserId ?? item.Summary);
return item.Source.ToLowerInvariant() switch
{
"clip" => [new AdminRiskEntityLinkDto("Clips öffnen", "clip", item.TwitchUserId ?? item.Id.ToString(), $"/admin/clips?query={query}")],
"nomination" => [new AdminRiskEntityLinkDto("Reviews öffnen", "nomination", item.TwitchUserId ?? item.Id.ToString(), $"/admin/reviews?query={query}")],
"vote" => [new AdminRiskEntityLinkDto("Voting-Analytics öffnen", "vote", item.TwitchUserId ?? item.Id.ToString(), $"/admin/analytics?query={query}")],
"login" => [new AdminRiskEntityLinkDto("Audit-Log öffnen", "session", item.TwitchUserId ?? item.Id.ToString(), $"/admin/users-logs?query={query}")],
_ => [],
};
}
private static IEnumerable<AdminRiskEntityLinkDto> ReadExplicitLinks(string metadataJson)
{
if (string.IsNullOrWhiteSpace(metadataJson))
{
yield break;
}
JsonDocument document;
try
{
document = JsonDocument.Parse(metadataJson);
}
catch (JsonException)
{
yield break;
}
using (document)
{
if (TryReadEntityLinks(document.RootElement, out var entityLinks))
{
foreach (var link in entityLinks)
{
yield return link;
}
}
}
}
private static bool TryReadEntityLinks(JsonElement root, out AdminRiskEntityLinkDto[] links)
{
links = [];
if (root.ValueKind != JsonValueKind.Object || !TryGetProperty(root, "entityLinks", out var entityLinks) || entityLinks.ValueKind != JsonValueKind.Array)
{
return false;
}
links = entityLinks.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.Object)
.Select(item => new AdminRiskEntityLinkDto(
ReadString(item, "label"),
ReadString(item, "entityType"),
ReadString(item, "entityId"),
ReadString(item, "to")))
.Where(item => !string.IsNullOrWhiteSpace(item.Label) && !string.IsNullOrWhiteSpace(item.To))
.ToArray();
return links.Length > 0;
}
private static bool TryGetProperty(JsonElement root, string propertyName, out JsonElement value)
{
foreach (var property in root.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
private static string ReadString(JsonElement root, string propertyName)
{
if (!TryGetProperty(root, propertyName, out var value))
{
return string.Empty;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Number => value.GetRawText(),
_ => string.Empty,
};
}
}
@@ -0,0 +1,149 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> CreateCandidate(
HttpContext context,
int seasonId,
UpsertCandidateRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validationError = ValidateCandidateRequest(request);
if (validationError is not null)
{
return validationError;
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId);
if (category is null)
{
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
}
var normalizedDisplayName = request.DisplayName.Trim();
var normalizedChannelSlug = request.ChannelSlug.Trim();
if (await db.Candidates.AnyAsync(item =>
item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
{
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
}
var candidate = new Candidate
{
SeasonId = seasonId,
CategoryId = request.CategoryId,
DisplayName = normalizedDisplayName,
ChannelSlug = normalizedChannelSlug,
Platform = request.Platform.Trim(),
};
db.Candidates.Add(candidate);
adminAuditService.AddEntry(
session.TwitchUserId,
"candidate.create",
"candidate",
request.DisplayName.Trim(),
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
new { seasonId, request.CategoryId, request.Platform },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, candidateId = candidate.Id });
}
private static async Task<IResult> UpdateCandidate(
HttpContext context,
int candidateId,
UpsertCandidateRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validationError = ValidateCandidateRequest(request);
if (validationError is not null)
{
return validationError;
}
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
if (candidate is null)
{
return Results.NotFound();
}
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
item.Id == request.CategoryId && item.SeasonId == candidate.SeasonId);
if (targetCategory is null)
{
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
}
var normalizedDisplayName = request.DisplayName.Trim();
var normalizedChannelSlug = request.ChannelSlug.Trim();
if (await db.Candidates.AnyAsync(item =>
item.SeasonId == candidate.SeasonId
&& item.CategoryId == request.CategoryId
&& item.Id != candidateId
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
{
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
}
candidate.CategoryId = request.CategoryId;
candidate.DisplayName = normalizedDisplayName;
candidate.ChannelSlug = normalizedChannelSlug;
candidate.Platform = request.Platform.Trim();
adminAuditService.AddEntry(
session.TwitchUserId,
"candidate.update",
"candidate",
candidate.Id.ToString(),
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
new { request.CategoryId, request.Platform },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, candidateId = candidate.Id });
}
private static async Task<IResult> DeleteCandidate(
HttpContext context,
int candidateId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
if (candidate is null)
{
return Results.NotFound();
}
db.Candidates.Remove(candidate);
adminAuditService.AddEntry(
session.TwitchUserId,
"candidate.delete",
"candidate",
candidate.Id.ToString(),
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
new { candidate.CategoryId, candidate.Platform },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, candidateId });
}
}
@@ -0,0 +1,146 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> CreateCategory(
HttpContext context,
int seasonId,
UpsertCategoryRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validationError = ValidateCategoryRequest(request);
if (validationError is not null)
{
return validationError;
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var normalizedSlug = request.Slug.Trim();
if (await db.Categories.AnyAsync(item =>
item.SeasonId == seasonId
&& item.Slug.ToLower() == normalizedSlug.ToLower()))
{
return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." });
}
var category = new Category
{
SeasonId = seasonId,
GroupName = request.GroupName.Trim(),
Name = request.Name.Trim(),
Slug = normalizedSlug,
Description = request.Description.Trim(),
SortOrder = request.SortOrder,
MaxNomineesPerUser = request.MaxNomineesPerUser,
};
db.Categories.Add(category);
adminAuditService.AddEntry(
session.TwitchUserId,
"category.create",
"category",
request.Slug.Trim(),
$"Kategorie {request.Name.Trim()} wurde angelegt.",
new { seasonId, request.GroupName, request.SortOrder },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, categoryId = category.Id });
}
private static async Task<IResult> UpdateCategory(
HttpContext context,
int categoryId,
UpsertCategoryRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validationError = ValidateCategoryRequest(request);
if (validationError is not null)
{
return validationError;
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
if (category is null)
{
return Results.NotFound();
}
var normalizedSlug = request.Slug.Trim();
if (await db.Categories.AnyAsync(item =>
item.SeasonId == category.SeasonId
&& item.Id != categoryId
&& item.Slug.ToLower() == normalizedSlug.ToLower()))
{
return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." });
}
category.GroupName = request.GroupName.Trim();
category.Name = request.Name.Trim();
category.Slug = normalizedSlug;
category.Description = request.Description.Trim();
category.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
adminAuditService.AddEntry(
session.TwitchUserId,
"category.update",
"category",
category.Id.ToString(),
$"Kategorie {request.Name.Trim()} wurde aktualisiert.",
new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, categoryId = category.Id });
}
private static async Task<IResult> DeleteCategory(
HttpContext context,
int categoryId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
if (category is null)
{
return Results.NotFound();
}
var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync();
if (candidates.Length > 0)
{
db.Candidates.RemoveRange(candidates);
}
db.Categories.Remove(category);
adminAuditService.AddEntry(
session.TwitchUserId,
"category.delete",
"category",
category.Id.ToString(),
$"Kategorie {category.Name} wurde gelöscht.",
new { removedCandidates = candidates.Length },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, categoryId });
}
}
@@ -0,0 +1,125 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> CreateSeason(
HttpContext context,
CreateSeasonRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validationError = ValidateSeasonRequest(request);
if (validationError is not null)
{
return validationError;
}
if (await db.Seasons.AnyAsync(item => item.Year == request.Year))
{
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
}
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
var season = new Season
{
Year = request.Year,
Name = request.Name.Trim(),
ShowStreamUrl = showStreamUrl,
CurrentPhase = request.CurrentPhase.Trim(),
IsCurrent = request.IsCurrent,
IsCommunityOnly = request.IsCommunityOnly,
NominationStartsAt = request.NominationStartsAt,
NominationEndsAt = request.NominationEndsAt,
VotingStartsAt = request.VotingStartsAt,
VotingEndsAt = request.VotingEndsAt,
ReviewStartsAt = request.ReviewStartsAt,
ReviewEndsAt = request.ReviewEndsAt,
ShowDate = request.ShowDate,
ShowStartsAt = request.ShowStartsAt,
};
db.Seasons.Add(season);
var copiedCategoryCount = 0;
if (request.CopyStructureFromSeasonId is { } sourceSeasonId)
{
if (sourceSeasonId <= 0)
{
return Results.BadRequest(new { message = "A valid source season is required for copying structure." });
}
var sourceSeasonExists = await db.Seasons
.AsNoTracking()
.AnyAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
if (!sourceSeasonExists)
{
return Results.BadRequest(new { message = "Source season for structure copy was not found." });
}
var sourceCategories = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == sourceSeasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToArrayAsync(context.RequestAborted);
copiedCategoryCount = sourceCategories.Length;
foreach (var category in sourceCategories)
{
db.Categories.Add(new Category
{
Season = season,
GroupName = category.GroupName,
Name = category.Name,
Slug = category.Slug,
Description = category.Description,
SortOrder = category.SortOrder,
MaxNomineesPerUser = category.MaxNomineesPerUser,
});
}
}
var readinessIssues = BuildNewSeasonReadinessIssues(
request.CurrentPhase,
request.IsCurrent,
copiedCategoryCount);
if (readinessIssues.Length > 0)
{
return CreateReadinessError(readinessIssues);
}
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent, null, context.RequestAborted);
adminAuditService.AddEntry(
session.TwitchUserId,
"season.create",
"season",
request.Year.ToString(),
copiedCategoryCount > 0
? $"Season {request.Year} wurde angelegt und {copiedCategoryCount} Kategorien wurden kopiert."
: $"Season {request.Year} wurde angelegt.",
new
{
request.IsCurrent,
request.IsCommunityOnly,
showStreamUrl,
request.CurrentPhase,
request.ShowDate,
request.ShowStartsAt,
request.CopyStructureFromSeasonId,
copiedCategoryCount,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, seasonId = season.Id, copiedCategoryCount });
}
}
@@ -0,0 +1,71 @@
using Backend.Common;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> DeleteSeason(
HttpContext context,
int seasonId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
if (season.IsCurrent)
{
return Results.BadRequest(new { message = "Das öffentlich aktive Award-Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich." });
}
await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted);
var deletionSummary = await DeleteSeasonRelationsAsync(db, seasonId, context.RequestAborted);
db.Seasons.Remove(season);
adminAuditService.AddEntry(
session.TwitchUserId,
"season.delete",
"season",
season.Id.ToString(),
$"Season {season.Year} wurde gelöscht.",
new
{
season.Year,
deletionSummary.DeletedVoteEntries,
deletionSummary.DeletedBallots,
deletionSummary.DeletedResults,
deletionSummary.DeletedNominations,
deletionSummary.DeletedClips,
deletionSummary.DeletedRiskFlags,
deletionSummary.DeletedCandidates,
deletionSummary.DeletedCategories,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
await transaction.CommitAsync(context.RequestAborted);
return Results.Ok(new
{
deleted = true,
seasonId,
season.Year,
deletedVoteEntries = deletionSummary.DeletedVoteEntries,
deletedBallots = deletionSummary.DeletedBallots,
deletedResults = deletionSummary.DeletedResults,
deletedNominations = deletionSummary.DeletedNominations,
deletedClips = deletionSummary.DeletedClips,
deletedRiskFlags = deletionSummary.DeletedRiskFlags,
deletedCandidates = deletionSummary.DeletedCandidates,
deletedCategories = deletionSummary.DeletedCategories,
});
}
}
@@ -0,0 +1,65 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private sealed record SeasonDeletionSummary(
int DeletedVoteEntries,
int DeletedBallots,
int DeletedResults,
int DeletedNominations,
int DeletedClips,
int DeletedRiskFlags,
int DeletedCandidates,
int DeletedCategories);
private static async Task<SeasonDeletionSummary> DeleteSeasonRelationsAsync(
AwardsDbContext db,
int seasonId,
CancellationToken cancellationToken)
{
var ballotIds = await db.VoteBallots
.Where(item => item.SeasonId == seasonId)
.Select(item => item.Id)
.ToArrayAsync(cancellationToken);
var deletedVoteEntries = ballotIds.Length == 0
? 0
: await db.VoteEntries
.Where(item => ballotIds.Contains(item.BallotId))
.ExecuteDeleteAsync(cancellationToken);
var deletedBallots = await db.VoteBallots
.Where(item => item.SeasonId == seasonId)
.ExecuteDeleteAsync(cancellationToken);
var deletedResults = await db.Results
.Where(item => item.SeasonId == seasonId)
.ExecuteDeleteAsync(cancellationToken);
var deletedNominations = await db.Nominations
.Where(item => item.SeasonId == seasonId)
.ExecuteDeleteAsync(cancellationToken);
var deletedClips = await db.ClipSubmissions
.Where(item => item.SeasonId == seasonId)
.ExecuteDeleteAsync(cancellationToken);
var deletedRiskFlags = await db.RiskFlags
.Where(item => item.SeasonId == seasonId)
.ExecuteDeleteAsync(cancellationToken);
var deletedCandidates = await db.Candidates
.Where(item => item.SeasonId == seasonId)
.ExecuteDeleteAsync(cancellationToken);
var deletedCategories = await db.Categories
.Where(item => item.SeasonId == seasonId)
.ExecuteDeleteAsync(cancellationToken);
return new SeasonDeletionSummary(
deletedVoteEntries,
deletedBallots,
deletedResults,
deletedNominations,
deletedClips,
deletedRiskFlags,
deletedCandidates,
deletedCategories);
}
}
@@ -0,0 +1,163 @@
using Backend.Contracts;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> GetSeasonDetail(int seasonId, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var candidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.DisplayName)
.Select(item => new AdminCandidateItemDto(
item.Id,
item.CategoryId,
item.DisplayName,
item.ChannelSlug,
item.Platform))
.ToArrayAsync();
var candidateCounts = candidates
.GroupBy(item => item.CategoryId)
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
var categoryRows = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(category => new
{
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
})
.ToArrayAsync();
var categories = categoryRows
.Select(category => new AdminCategoryItemDto(
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
.ToArray();
var pendingNominations = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status == "pending" && item.CandidateText != null)
.OrderByDescending(item => item.CreatedAt)
.Select(item => new AdminNominationReviewItemDto(
item.Id,
item.CategoryId,
item.Category.Name,
item.SubmittedByTwitchId,
item.CandidateText!,
item.StreamUrl,
item.Status,
item.CreatedAt,
item.CandidateId,
item.CandidateId != null ? item.Candidate!.DisplayName : null,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt))
.ToArrayAsync();
var reviewedNominations = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
.Select(item => new AdminNominationReviewItemDto(
item.Id,
item.CategoryId,
item.Category.Name,
item.SubmittedByTwitchId,
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
item.StreamUrl,
item.Status,
item.CreatedAt,
item.CandidateId,
item.CandidateId != null ? item.Candidate!.DisplayName : null,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt))
.ToArrayAsync();
var resultItems = await db.Results
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.Category.SortOrder)
.ThenBy(item => item.Category.Name)
.Select(item => new AdminAwardResultItemDto(
item.Id,
item.CategoryId,
item.Category.Name,
item.CandidateId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
item.Candidate.Platform))
.ToArrayAsync();
var clipSubmissions = await db.ClipSubmissions
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderByDescending(item => item.CreatedAt)
.Select(item => new AdminClipSubmissionItemDto(
item.Id,
item.CategoryId,
item.CandidateId,
item.SubmittedByTwitchId,
item.ClipUrl,
item.Title,
item.Creator,
item.Platform,
item.Status,
item.CreatedAt,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt))
.ToArrayAsync();
return Results.Ok(new AdminSeasonDetailResponse(
season.Id,
season.Year,
season.Name,
NormalizeSeasonStreamUrl(season.ShowStreamUrl),
season.CurrentPhase,
season.IsCurrent,
season.IsCommunityOnly,
season.NominationStartsAt,
season.NominationEndsAt,
season.VotingStartsAt,
season.VotingEndsAt,
season.ReviewStartsAt,
season.ReviewEndsAt,
season.ShowDate,
season.ShowStartsAt,
categories,
candidates,
pendingNominations,
reviewedNominations,
resultItems,
clipSubmissions));
}
}
@@ -0,0 +1,25 @@
using Backend.Contracts;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> GetSeasons(AwardsDbContext db)
{
var seasons = await db.Seasons
.AsNoTracking()
.OrderByDescending(item => item.Year)
.Select(item => new AdminSeasonListItemDto(
item.Id,
item.Year,
item.Name,
item.CurrentPhase,
item.IsCurrent,
item.Categories.Count))
.ToArrayAsync();
return Results.Ok(seasons);
}
}
@@ -0,0 +1,22 @@
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/seasons", GetSeasons).WithName("GetAdminSeasons").WithOpenApi();
group.MapPost("/seasons", CreateSeason).WithName("CreateAdminSeason").WithOpenApi();
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail).WithName("GetAdminSeasonDetail").WithOpenApi();
group.MapPut("/seasons/{seasonId:int}", UpdateSeason).WithName("UpdateAdminSeason").WithOpenApi();
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason).WithName("DeleteAdminSeason").WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory).WithName("CreateAdminCategory").WithOpenApi();
group.MapPut("/categories/{categoryId:int}", UpdateCategory).WithName("UpdateAdminCategory").WithOpenApi();
group.MapDelete("/categories/{categoryId:int}", DeleteCategory).WithName("DeleteAdminCategory").WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate).WithName("CreateAdminCandidate").WithOpenApi();
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate).WithName("UpdateAdminCandidate").WithOpenApi();
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate).WithName("DeleteAdminCandidate").WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/results", SetResult).WithName("SetAdminResult").WithOpenApi();
group.MapDelete("/results/{resultId:int}", DeleteResult).WithName("DeleteAdminResult").WithOpenApi();
return group;
}
}
@@ -0,0 +1,313 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private const int MaxCategoryGroupNameLength = 80;
private const int MaxCategoryNameLength = 120;
private const int MaxCategorySlugLength = 120;
private const int MaxCategoryDescriptionLength = 600;
private const int MaxCandidateDisplayNameLength = 120;
private const int MaxCandidateChannelSlugLength = 120;
private const int MaxCandidatePlatformLength = 60;
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
{
if (request.Year < 2020 || request.Year > 2100)
{
return Results.BadRequest(new { message = "Please provide a valid award year." });
}
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
{
return Results.BadRequest(new { message = "Season name and current phase are required." });
}
if (!IsKnownSeasonPhase(request.CurrentPhase))
{
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
}
if (!SeasonMappings.IsSeasonScheduleValid(
request.NominationStartsAt,
request.NominationEndsAt,
request.VotingStartsAt,
request.VotingEndsAt,
request.ReviewStartsAt,
request.ReviewEndsAt,
request.ShowDate))
{
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
}
return null;
}
private static IResult? ValidateSeasonRequest(UpdateSeasonRequest request)
{
if (request.Year < 2020 || request.Year > 2100)
{
return Results.BadRequest(new { message = "Please provide a valid award year." });
}
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
{
return Results.BadRequest(new { message = "Season name and current phase are required." });
}
if (!IsKnownSeasonPhase(request.CurrentPhase))
{
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
}
if (!SeasonMappings.IsSeasonScheduleValid(
request.NominationStartsAt,
request.NominationEndsAt,
request.VotingStartsAt,
request.VotingEndsAt,
request.ReviewStartsAt,
request.ReviewEndsAt,
request.ShowDate))
{
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
}
return null;
}
private static string NormalizeSeasonStreamUrl(string? showStreamUrl)
{
return SeasonMappings.NormalizeSeasonStreamUrl(showStreamUrl);
}
private static bool IsKnownSeasonPhase(string? currentPhase)
{
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
return value.Contains("show")
|| value.Contains("abgeschlossen")
|| value.Contains("archiv")
|| value.Contains("complete")
|| value.Contains("ended")
|| value.Contains("review")
|| value.Contains("auswert")
|| value.Contains("vot")
|| value.Contains("nomin");
}
private static async Task UnsetOtherCurrentSeasonsAsync(
AwardsDbContext db,
bool shouldUnsetOthers,
int? seasonIdToKeep,
CancellationToken cancellationToken)
{
if (!shouldUnsetOthers)
{
return;
}
var activeSeasons = await db.Seasons
.Where(item => item.IsCurrent && (!seasonIdToKeep.HasValue || item.Id != seasonIdToKeep.Value))
.ToListAsync(cancellationToken);
foreach (var activeSeason in activeSeasons)
{
activeSeason.IsCurrent = false;
}
}
private static IResult CreateReadinessError(IEnumerable<string> issues)
{
var issueList = issues.ToArray();
return Results.BadRequest(new
{
message = $"Public-/Archiv-Readiness blockiert: {string.Join(" ", issueList)}",
issues = issueList,
});
}
private static string[] BuildNewSeasonReadinessIssues(
string currentPhase,
bool isCurrent,
int copiedCategoryCount)
{
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
if (!isCurrent && !needsWinnerReadiness)
{
return [];
}
var issues = new List<string>();
if (copiedCategoryCount <= 0)
{
issues.Add("Mindestens eine Kategorie ist erforderlich.");
}
if (needsCandidateReadiness)
{
issues.Add("Kandidaten muessen vor dieser Phase fuer alle Kategorien gepflegt sein.");
}
if (needsWinnerReadiness)
{
issues.Add("Abgeschlossen ist erst moeglich, wenn jede Kategorie einen Gewinner hat.");
}
return issues.ToArray();
}
private static async Task<string[]> BuildSeasonReadinessIssuesAsync(
AwardsDbContext db,
int seasonId,
string currentPhase,
bool isCurrent,
CancellationToken cancellationToken)
{
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
if (!isCurrent && !needsWinnerReadiness)
{
return [];
}
var categoryIds = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => item.Id)
.ToArrayAsync(cancellationToken);
var issues = new List<string>();
if (categoryIds.Length == 0)
{
issues.Add("Mindestens eine Kategorie ist erforderlich.");
}
if (needsCandidateReadiness && categoryIds.Length > 0)
{
var categoriesWithCandidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => item.CategoryId)
.Distinct()
.CountAsync(cancellationToken);
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
if (emptyCategories > 0)
{
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
}
}
if (needsWinnerReadiness && categoryIds.Length > 0)
{
var categoriesWithResults = await db.Results
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.Select(item => item.CategoryId)
.Distinct()
.CountAsync(cancellationToken);
var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults);
if (missingResults > 0)
{
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
}
}
return issues.ToArray();
}
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
{
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|| string.Equals(phaseKey, "completed", StringComparison.Ordinal);
}
private static bool RequiresWinnerReadiness(string phaseKey)
{
return string.Equals(phaseKey, "completed", StringComparison.Ordinal);
}
private static IResult? ValidateCategoryRequest(UpsertCategoryRequest request)
{
var groupName = request.GroupName.Trim();
var name = request.Name.Trim();
var slug = request.Slug.Trim();
var description = request.Description.Trim();
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > MaxCategoryGroupNameLength)
{
return Results.BadRequest(new { message = $"Category group name is required and must stay below {MaxCategoryGroupNameLength} characters." });
}
if (string.IsNullOrWhiteSpace(name) || name.Length > MaxCategoryNameLength)
{
return Results.BadRequest(new { message = $"Category name is required and must stay below {MaxCategoryNameLength} characters." });
}
if (string.IsNullOrWhiteSpace(slug) || slug.Length > MaxCategorySlugLength)
{
return Results.BadRequest(new { message = $"Category slug is required and must stay below {MaxCategorySlugLength} characters." });
}
if (!slug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_'))
{
return Results.BadRequest(new { message = "Category slug contains unsupported characters." });
}
if (description.Length > MaxCategoryDescriptionLength)
{
return Results.BadRequest(new { message = $"Category description must stay below {MaxCategoryDescriptionLength} characters." });
}
if (request.SortOrder is < 0 or > 500)
{
return Results.BadRequest(new { message = "Category sort order must be between 0 and 500." });
}
if (request.MaxNomineesPerUser is < 1 or > 10)
{
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
}
return null;
}
private static IResult? ValidateCandidateRequest(UpsertCandidateRequest request)
{
var displayName = request.DisplayName.Trim();
var channelSlug = request.ChannelSlug.Trim();
var platform = request.Platform.Trim();
if (request.CategoryId <= 0)
{
return Results.BadRequest(new { message = "A valid category is required." });
}
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > MaxCandidateDisplayNameLength)
{
return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxCandidateDisplayNameLength} characters." });
}
if (string.IsNullOrWhiteSpace(channelSlug) || channelSlug.Length > MaxCandidateChannelSlugLength)
{
return Results.BadRequest(new { message = $"Channel slug is required and must stay below {MaxCandidateChannelSlugLength} characters." });
}
if (!channelSlug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_' or '.'))
{
return Results.BadRequest(new { message = "Channel slug contains unsupported characters." });
}
if (string.IsNullOrWhiteSpace(platform) || platform.Length > MaxCandidatePlatformLength)
{
return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." });
}
return null;
}
}
@@ -0,0 +1,121 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> SetResult(
HttpContext context,
int seasonId,
SetAwardResultRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var category = await db.Categories
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId);
if (category is null)
{
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
}
var candidate = await db.Candidates
.AsNoTracking()
.FirstOrDefaultAsync(item =>
item.Id == request.CandidateId
&& item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId);
if (candidate is null)
{
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
}
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId);
if (existingResult is null)
{
existingResult = new AwardResult
{
SeasonId = seasonId,
CategoryId = request.CategoryId,
CandidateId = request.CandidateId,
CategoryName = category.Name,
};
db.Results.Add(existingResult);
}
else
{
existingResult.CandidateId = request.CandidateId;
existingResult.CategoryName = category.Name;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"result.set",
"result",
$"{seasonId}:{request.CategoryId}",
$"Gewinner für {category.Name} wurde gesetzt.",
new
{
seasonId,
categoryId = request.CategoryId,
candidateId = request.CandidateId,
candidateName = candidate.DisplayName,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new
{
saved = true,
resultId = existingResult.Id,
seasonId,
categoryId = request.CategoryId,
candidateId = request.CandidateId,
});
}
private static async Task<IResult> DeleteResult(
HttpContext context,
int resultId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var result = await db.Results
.Include(item => item.Category)
.Include(item => item.Candidate)
.FirstOrDefaultAsync(item => item.Id == resultId);
if (result is null)
{
return Results.NotFound();
}
db.Results.Remove(result);
adminAuditService.AddEntry(
session.TwitchUserId,
"result.delete",
"result",
result.Id.ToString(),
$"Gewinner für {result.Category.Name} wurde entfernt.",
new
{
result.SeasonId,
result.CategoryId,
result.CandidateId,
candidateName = result.Candidate.DisplayName,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, resultId });
}
}
@@ -0,0 +1,115 @@
using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> UpdateSeason(
HttpContext context,
int seasonId,
UpdateSeasonRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var validationError = ValidateSeasonRequest(request);
if (validationError is not null)
{
return validationError;
}
if (await db.Seasons.AnyAsync(item => item.Id != seasonId && item.Year == request.Year))
{
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
}
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
var wasCurrent = season.IsCurrent;
var previousPhase = season.CurrentPhase;
var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase);
var requestedPhaseKey = SeasonMappings.NormalizePhaseKey(request.CurrentPhase);
var shouldValidateReadiness = request.IsCurrent
|| (string.Equals(requestedPhaseKey, "completed", StringComparison.Ordinal)
&& !string.Equals(previousPhaseKey, "completed", StringComparison.Ordinal));
if (shouldValidateReadiness)
{
var readinessIssues = await BuildSeasonReadinessIssuesAsync(
db,
seasonId,
request.CurrentPhase,
request.IsCurrent,
context.RequestAborted);
if (readinessIssues.Length > 0)
{
return CreateReadinessError(readinessIssues);
}
}
season.Year = request.Year;
season.Name = request.Name.Trim();
season.ShowStreamUrl = showStreamUrl;
season.CurrentPhase = request.CurrentPhase.Trim();
season.IsCommunityOnly = request.IsCommunityOnly;
season.NominationStartsAt = request.NominationStartsAt;
season.NominationEndsAt = request.NominationEndsAt;
season.VotingStartsAt = request.VotingStartsAt;
season.VotingEndsAt = request.VotingEndsAt;
season.ReviewStartsAt = request.ReviewStartsAt;
season.ReviewEndsAt = request.ReviewEndsAt;
season.ShowDate = request.ShowDate;
season.ShowStartsAt = request.ShowStartsAt;
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent && !wasCurrent, seasonId, context.RequestAborted);
season.IsCurrent = request.IsCurrent;
var actionType = "season.update";
var summary = $"Season {season.Year} wurde aktualisiert.";
if (!string.Equals(previousPhase.Trim(), season.CurrentPhase, StringComparison.OrdinalIgnoreCase))
{
actionType = "season.phase.update";
summary = $"Phase fuer Season {season.Year} wurde auf {season.CurrentPhase} gesetzt.";
}
else if (wasCurrent != request.IsCurrent)
{
actionType = "season.public.update";
summary = request.IsCurrent
? $"Season {season.Year} wurde als Public-Kontext aktiviert."
: $"Season {season.Year} wurde aus dem Public-Kontext entfernt.";
}
adminAuditService.AddEntry(
session.TwitchUserId,
actionType,
"season",
season.Id.ToString(),
summary,
new
{
request.Year,
request.Name,
showStreamUrl,
previousPhase,
request.CurrentPhase,
wasCurrent,
request.IsCurrent,
request.IsCommunityOnly,
request.ShowDate,
request.ShowStartsAt,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, seasonId = season.Id });
}
}
@@ -0,0 +1,353 @@
using System.Text.Json;
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminSiteSettingsEndpoints
{
private const string FallbackMaintenanceTitle = "Sternenpause";
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/site-settings", GetSiteSettings).WithName("GetAdminSiteSettings").WithOpenApi();
group.MapPut("/site-settings", UpdateSiteSettings).WithName("UpdateAdminSiteSettings").WithOpenApi();
group.MapGet("/operational-settings", GetOperationalSettings).WithName("GetAdminOperationalSettings").WithOpenApi();
group.MapPut("/operational-settings", UpdateOperationalSettings).WithName("UpdateAdminOperationalSettings").WithOpenApi();
return group;
}
private static async Task<IResult> GetSiteSettings(AwardsDbContext db)
{
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(new AdminSiteSettingsResponse(
settings.HostDisplayName,
settings.HostTagline,
settings.NewsletterUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ContactUrl,
settings.SponsorsUrl,
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings)));
}
private static async Task<IResult> UpdateSiteSettings(
HttpContext context,
UpdateSiteSettingsRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanManageContent(session.Role))
{
return Results.Json(new { message = "Landingpage content requires a content admin, admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.NewsletterUrl = request.NewsletterUrl.Trim();
settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
settings.PrivacyPolicyContent = trimmedPrivacyContent;
if (privacyChanged)
{
settings.PrivacyPolicyUpdatedBy = session.DisplayName.Trim();
settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow;
}
settings.ImprintUrl = request.ImprintUrl.Trim();
settings.ContactUrl = request.ContactUrl.Trim();
settings.SponsorsUrl = request.SponsorsUrl.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
adminAuditService.AddEntry(
session.TwitchUserId,
"site-settings.update",
"site-settings",
settings.Id.ToString(),
"Public Site Settings wurden aktualisiert.",
new
{
settings.HostDisplayName,
privacyChanged,
socialLinkCount = request.SocialLinks?.Length ?? 0,
faqCount = request.Faq?.Length ?? 0,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true });
}
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
return Results.Ok(new AdminOperationalSettingsResponse(
usesDatabaseDemo,
usesDatabaseDemo ? settings.DemoLoginEnabled : IsDemoLoginEnabled(configuration),
usesDatabaseDemo ? settings.DemoLoginEmail : ReadDemoLoginIdentifier(configuration),
usesDatabaseDemo ? HasDatabaseDemoCredentials(settings) : !string.IsNullOrWhiteSpace(ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD")),
usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"),
usesDatabaseDemo ? settings.DemoLoginDisplayName : ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"),
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
? FallbackMaintenanceMessage
: settings.MaintenanceMessage));
}
private static async Task<IResult> UpdateOperationalSettings(
HttpContext context,
UpdateOperationalSettingsRequest request,
AwardsDbContext db,
IConfiguration configuration,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanManageOperationalSettings(session.Role))
{
return Results.Json(new { message = "Demo-Zugang und Wartungsmodus können nur Owner ändern." }, statusCode: StatusCodes.Status403Forbidden);
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var before = CreateOperationalSettingsSnapshot(settings);
var loginIdentifier = request.DemoLoginEmail.Trim();
var twitchUserId = request.DemoLoginTwitchUserId.Trim();
var displayName = request.DemoLoginDisplayName.Trim();
var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty;
var fallbackPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
if (request.DemoLoginEnabled)
{
if (string.IsNullOrWhiteSpace(loginIdentifier)
|| string.IsNullOrWhiteSpace(twitchUserId)
|| string.IsNullOrWhiteSpace(displayName))
{
return Results.BadRequest(new { message = "Demo-Login braucht Login, Twitch-ID und Anzeigenamen." });
}
if (string.IsNullOrWhiteSpace(newPassword)
&& !HasDatabaseDemoCredentials(settings)
&& string.IsNullOrWhiteSpace(fallbackPassword))
{
return Results.BadRequest(new { message = "Bitte setze beim ersten Aktivieren ein Demo-Passwort." });
}
}
if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 12)
{
return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 12 Zeichen lang sein." });
}
settings.DemoLoginManagedByDatabase = true;
settings.DemoLoginEnabled = request.DemoLoginEnabled;
settings.DemoLoginEmail = loginIdentifier;
settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId;
settings.DemoLoginDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Jayuhime Admin" : displayName;
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
? newPassword
: request.DemoLoginEnabled && !HasDatabaseDemoCredentials(settings)
? fallbackPassword
: string.Empty;
if (!string.IsNullOrWhiteSpace(passwordToPersist))
{
var credentials = DemoCredentialHasher.HashPassword(passwordToPersist);
settings.DemoLoginPasswordHash = credentials.Hash;
settings.DemoLoginPasswordSalt = credentials.Salt;
}
settings.MaintenanceModeEnabled = request.MaintenanceModeEnabled;
settings.MaintenanceTitle = NormalizeOperationalText(request.MaintenanceTitle, FallbackMaintenanceTitle, 120);
settings.MaintenanceMessage = NormalizeOperationalText(
request.MaintenanceMessage,
FallbackMaintenanceMessage,
600);
var changes = BuildOperationalSettingChanges(
before,
CreateOperationalSettingsSnapshot(settings),
!string.IsNullOrWhiteSpace(passwordToPersist));
adminAuditService.AddEntry(
session.TwitchUserId,
"operational-settings.update",
"site-settings",
settings.Id.ToString(),
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
new
{
settings.DemoLoginEnabled,
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
settings.MaintenanceModeEnabled,
changes,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new
{
saved = true,
demoLoginPasswordSet = HasDatabaseDemoCredentials(settings),
});
}
private static string NormalizeOperationalText(string value, string fallback, int maxLength)
{
var trimmed = value.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return fallback;
}
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private static bool IsDemoLoginEnabled(IConfiguration configuration)
{
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
?? configuration["DemoAdmin:Enabled"];
return bool.TryParse(rawValue, out var enabled) && enabled;
}
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
{
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
return string.IsNullOrWhiteSpace(configuredLogin)
? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL")
: configuredLogin;
}
private static bool HasDatabaseDemoCredentials(SiteSettings settings) =>
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings) =>
new(
settings.DemoLoginManagedByDatabase,
settings.DemoLoginEnabled,
settings.DemoLoginEmail,
HasDatabaseDemoCredentials(settings),
settings.DemoLoginTwitchUserId,
settings.DemoLoginDisplayName,
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
private static object[] BuildOperationalSettingChanges(
OperationalSettingsSnapshot before,
OperationalSettingsSnapshot after,
bool passwordChanged)
{
var changes = new List<object>();
AddOperationalChange(changes, "demoLoginManagedByDatabase", "Demo Quelle", before.DemoLoginManagedByDatabase, after.DemoLoginManagedByDatabase);
AddOperationalChange(changes, "demoLoginEnabled", "Demo Login", before.DemoLoginEnabled, after.DemoLoginEnabled);
AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail);
AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId);
AddOperationalChange(changes, "demoLoginDisplayName", "Demo Anzeigename", before.DemoLoginDisplayName, after.DemoLoginDisplayName);
if (passwordChanged)
{
changes.Add(new
{
field = "demoLoginPassword",
label = "Demo Passwort",
@from = before.DemoLoginPasswordSet ? "gesetzt" : "nicht gesetzt",
to = "neu gesetzt",
sensitive = true,
});
}
AddOperationalChange(changes, "maintenanceModeEnabled", "Wartungsmodus", before.MaintenanceModeEnabled, after.MaintenanceModeEnabled);
AddOperationalChange(changes, "maintenanceTitle", "Wartungstitel", before.MaintenanceTitle, after.MaintenanceTitle);
AddOperationalChange(changes, "maintenanceMessage", "Wartungstext", before.MaintenanceMessage, after.MaintenanceMessage);
return changes.ToArray();
}
private static void AddOperationalChange<T>(
ICollection<object> changes,
string field,
string label,
T before,
T after)
{
if (EqualityComparer<T>.Default.Equals(before, after))
{
return;
}
changes.Add(new
{
field,
label,
@from = FormatOperationalAuditValue(before),
to = FormatOperationalAuditValue(after),
sensitive = false,
});
}
private static string FormatOperationalAuditValue<T>(T value)
{
if (value is bool booleanValue)
{
return booleanValue ? "aktiv" : "aus";
}
var text = Convert.ToString(value)?.Trim() ?? string.Empty;
return string.IsNullOrWhiteSpace(text) ? "leer" : text;
}
private sealed record OperationalSettingsSnapshot(
bool DemoLoginManagedByDatabase,
bool DemoLoginEnabled,
string DemoLoginEmail,
bool DemoLoginPasswordSet,
string DemoLoginTwitchUserId,
string DemoLoginDisplayName,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
}
@@ -0,0 +1,70 @@
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
private static async Task<IResult> DeleteMyParticipationData(
HttpContext context,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
var twitchUserId = session.TwitchUserId;
await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted);
var ballotIds = await db.VoteBallots
.Where(item => item.SubmittedByTwitchId == twitchUserId)
.Select(item => item.Id)
.ToArrayAsync(context.RequestAborted);
var deletedVoteEntries = ballotIds.Length == 0
? 0
: await db.VoteEntries
.Where(item => ballotIds.Contains(item.BallotId))
.ExecuteDeleteAsync(context.RequestAborted);
var deletedBallots = await db.VoteBallots
.Where(item => item.SubmittedByTwitchId == twitchUserId)
.ExecuteDeleteAsync(context.RequestAborted);
var deletedNominations = await db.Nominations
.Where(item => item.SubmittedByTwitchId == twitchUserId)
.ExecuteDeleteAsync(context.RequestAborted);
var deletedClips = await db.ClipSubmissions
.Where(item => item.SubmittedByTwitchId == twitchUserId)
.ExecuteDeleteAsync(context.RequestAborted);
var deletedRiskFlags = await db.RiskFlags
.Where(item => item.TwitchUserId == twitchUserId)
.ExecuteDeleteAsync(context.RequestAborted);
var disabledSessions = await db.UserSessions
.Where(item => item.TwitchUserId == twitchUserId)
.ExecuteUpdateAsync(
setters => setters.SetProperty(item => item.IsActive, false),
context.RequestAborted);
await transaction.CommitAsync(context.RequestAborted);
return Results.Ok(new
{
deleted = true,
twitchUserId,
deletedVoteEntries,
deletedBallots,
deletedNominations,
deletedClips,
deletedRiskFlags,
disabledSessions,
});
}
}
+182
View File
@@ -0,0 +1,182 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
private static async Task<IResult> DemoLogin(
HttpContext context,
AwardsDbContext db,
IConfiguration configuration,
DemoLoginRequest request,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
var login = request.Login?.Trim() ?? request.Email?.Trim() ?? string.Empty;
var password = request.Password ?? string.Empty;
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
var databaseDemoConfigured = settings is not null
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
string twitchUserId;
string displayName;
bool credentialsMatch;
if (databaseDemoConfigured && settings is not null)
{
if (!settings.DemoLoginEnabled)
{
return Results.NotFound();
}
if (!HasDatabaseDemoCredentials(settings)
|| string.IsNullOrWhiteSpace(settings.DemoLoginTwitchUserId)
|| string.IsNullOrWhiteSpace(settings.DemoLoginDisplayName))
{
return Results.Json(
new { message = "Demo login is not fully configured." },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
credentialsMatch = LoginMatchesIdentifier(
login,
settings.DemoLoginEmail,
settings.DemoLoginTwitchUserId,
settings.DemoLoginDisplayName)
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
displayName = settings.DemoLoginDisplayName.Trim();
}
else
{
if (!IsDemoLoginEnabled(configuration))
{
return Results.NotFound();
}
var configuredLogin = ReadDemoLoginIdentifier(configuration);
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
if (string.IsNullOrWhiteSpace(configuredLogin)
|| string.IsNullOrWhiteSpace(configuredPassword)
|| string.IsNullOrWhiteSpace(twitchUserId)
|| string.IsNullOrWhiteSpace(displayName))
{
return Results.Json(
new { message = "Demo login is not fully configured." },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
credentialsMatch = LoginMatchesIdentifier(
login,
configuredLogin,
configuredEmail,
twitchUserId,
displayName)
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
twitchUserId = twitchUserId.Trim();
displayName = displayName.Trim();
}
if (!credentialsMatch)
{
return Results.Unauthorized();
}
var requestMetadata = RequestMetadataReader.Read(context);
var session = await userSessionService.CreateSessionAsync(
twitchUserId,
displayName,
AdminRoles.Owner,
requestMetadata,
context.RequestAborted);
var rapidDemoLoginRule = await riskRuleService.GetRuleAsync("rapid_demo_login_ip", context.RequestAborted);
var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync(
requestMetadata.ClientIp,
DateTimeOffset.UtcNow.AddMinutes(-rapidDemoLoginRule.WindowMinutes),
context.RequestAborted);
if (rapidDemoLoginRule.Enabled && recentSessionsFromIp >= rapidDemoLoginRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
null,
session.TwitchUserId,
"login",
"rapid_demo_login_ip",
rapidDemoLoginRule.Severity,
"Mehrere Demo-Admin-Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
requestMetadata,
new
{
recentSessionsFromIp,
threshold = rapidDemoLoginRule.Threshold,
windowMinutes = rapidDemoLoginRule.WindowMinutes,
entityLinks = new[]
{
new
{
label = "Audit-Log öffnen",
entityType = "session",
entityId = session.TwitchUserId,
to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}",
},
},
},
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(ToAuthSessionDto(session));
}
private static bool IsDemoLoginEnabled(IConfiguration configuration)
{
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
?? configuration["DemoAdmin:Enabled"];
return bool.TryParse(rawValue, out var enabled) && enabled;
}
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
{
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
return string.IsNullOrWhiteSpace(configuredLogin)
? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL")
: configuredLogin;
}
private static bool HasDatabaseDemoCredentials(SiteSettings settings) =>
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
private static bool LoginMatchesIdentifier(string login, params string?[] validIdentifiers)
{
var normalizedLogin = NormalizeLoginIdentifier(login);
if (string.IsNullOrWhiteSpace(normalizedLogin))
{
return false;
}
return validIdentifiers
.Select(NormalizeLoginIdentifier)
.Any(identifier => string.Equals(normalizedLogin, identifier, StringComparison.OrdinalIgnoreCase));
}
private static string NormalizeLoginIdentifier(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@');
}
@@ -0,0 +1,101 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Security;
using Backend.Services;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
private const int MaxTwitchUserIdLength = 64;
private const int MaxDisplayNameLength = 80;
private static async Task<IResult> DevLogin(
HttpContext context,
IHostEnvironment environment,
LoginRequest request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
if (!environment.IsDevelopment())
{
return Results.NotFound();
}
var normalizedTwitchUserId = request.TwitchUserId?.Trim() ?? string.Empty;
var normalizedDisplayName = request.DisplayName?.Trim() ?? string.Empty;
var normalizedRole = AdminRoles.Normalize(request.Role);
if (string.IsNullOrWhiteSpace(normalizedTwitchUserId) || normalizedTwitchUserId.Length > MaxTwitchUserIdLength)
{
return Results.BadRequest(new { message = $"Twitch user id is required and must stay below {MaxTwitchUserIdLength} characters." });
}
if (!normalizedTwitchUserId.All(value => char.IsLetterOrDigit(value) || value is '_' or '-'))
{
return Results.BadRequest(new { message = "Twitch user id contains unsupported characters." });
}
if (string.IsNullOrWhiteSpace(normalizedDisplayName) || normalizedDisplayName.Length > MaxDisplayNameLength)
{
return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxDisplayNameLength} characters." });
}
if (!AdminRoles.IsKnownRole(request.Role))
{
return Results.BadRequest(new { message = "Role must be viewer, content_admin, admin or owner." });
}
var requestMetadata = RequestMetadataReader.Read(context);
var session = await userSessionService.CreateDevSessionAsync(
request with
{
TwitchUserId = normalizedTwitchUserId,
DisplayName = normalizedDisplayName,
Role = normalizedRole,
},
requestMetadata,
context.RequestAborted);
var rapidLoginRule = await riskRuleService.GetRuleAsync("rapid_login_ip", context.RequestAborted);
var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync(
requestMetadata.ClientIp,
DateTimeOffset.UtcNow.AddMinutes(-rapidLoginRule.WindowMinutes),
context.RequestAborted);
if (rapidLoginRule.Enabled && recentSessionsFromIp >= rapidLoginRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
null,
session.TwitchUserId,
"login",
"rapid_login_ip",
rapidLoginRule.Severity,
"Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
requestMetadata,
new
{
recentSessionsFromIp,
threshold = rapidLoginRule.Threshold,
windowMinutes = rapidLoginRule.WindowMinutes,
entityLinks = new[]
{
new
{
label = "Audit-Log öffnen",
entityType = "session",
entityId = session.TwitchUserId,
to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}",
},
},
},
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(ToAuthSessionDto(session));
}
}
+37
View File
@@ -0,0 +1,37 @@
using Backend.Common;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth");
group.MapPost("/dev-login", DevLogin)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("DevLogin")
.WithOpenApi();
group.MapPost("/demo-login", DemoLogin)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("DemoLogin")
.WithOpenApi();
group.MapGet("/session", GetSession)
.WithName("GetSession")
.WithOpenApi();
group.MapPost("/logout", Logout)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("Logout")
.WithOpenApi();
group.MapDelete("/me/data", DeleteMyParticipationData)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("DeleteMyParticipationData")
.WithOpenApi();
return app;
}
}
+38
View File
@@ -0,0 +1,38 @@
using Backend.Contracts;
using Backend.Domain;
using Backend.Services;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
private static async Task<IResult> GetSession(HttpContext context, IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
return Results.Ok(ToAuthSessionDto(session));
}
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Ok(new { loggedOut = true });
}
await userSessionService.LogoutAsync(session, context.RequestAborted);
return Results.Ok(new { loggedOut = true });
}
private static AuthSessionDto ToAuthSessionDto(UserSession session) =>
new(
session.SessionToken,
session.TwitchUserId,
session.DisplayName,
session.Role);
}
+158
View File
@@ -0,0 +1,158 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> CreateClip(
HttpContext context,
CreateClipRequest request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl))
{
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
}
var platform = ResolveClipPlatform(clipUrl);
if (platform is null)
{
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
if (clipSeasonResolution.Result is not null)
{
return clipSeasonResolution.Result;
}
season = clipSeasonResolution.Season!;
var selectedCandidate = request.CandidateId is int candidateId
? await db.Candidates
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
: null;
if (request.CandidateId is not null && selectedCandidate is null)
{
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
}
if (request.CategoryId is int requestedCategoryId
&& selectedCandidate is not null
&& selectedCandidate.CategoryId != requestedCategoryId)
{
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
}
var resolvedCategoryId = request.CategoryId ?? selectedCandidate?.CategoryId;
var normalizedTitle = request.Title?.Trim() ?? string.Empty;
var submittedCreator = request.Creator?.Trim();
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
? selectedCandidate?.DisplayName ?? string.Empty
: submittedCreator;
if (normalizedTitle.Length > 160)
{
return Results.BadRequest(new { message = "Clip titles must stay below 160 characters." });
}
if (normalizedCreator.Length > 160)
{
return Results.BadRequest(new { message = "Creator names must stay below 160 characters." });
}
if (resolvedCategoryId is int categoryId)
{
var categoryExists = selectedCandidate?.CategoryId == categoryId
|| await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
if (!categoryExists)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
}
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
}
var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context);
var duplicateClipRule = await riskRuleService.GetRuleAsync("duplicate_clip_submission", context.RequestAborted);
var rapidClipBurstRule = await riskRuleService.GetRuleAsync("rapid_clip_burst", context.RequestAborted);
var recentClipSubmissions = await db.ClipSubmissions.CountAsync(item =>
item.SubmittedByTwitchId == submitterId
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidClipBurstRule.WindowMinutes));
var alreadySubmittedClip = await db.ClipSubmissions.AnyAsync(item =>
item.SeasonId == season.Id
&& item.SubmittedByTwitchId == submitterId
&& item.ClipUrl == clipUrl);
var clip = new ClipSubmission
{
SeasonId = season.Id,
CategoryId = resolvedCategoryId,
CandidateId = selectedCandidate?.Id,
SubmittedByTwitchId = submitterId,
ClipUrl = clipUrl,
Title = normalizedTitle,
Creator = normalizedCreator,
Platform = platform,
Status = "pending",
CreatedFromIp = requestMetadata.ClientIp,
CreatedAt = DateTimeOffset.UtcNow,
};
db.ClipSubmissions.Add(clip);
await db.SaveChangesAsync(context.RequestAborted);
var clipLink = new
{
label = "Clip öffnen",
entityType = "clip",
entityId = clip.Id.ToString(),
to = $"/admin/clips?query={Uri.EscapeDataString(clip.Id.ToString())}",
};
if (alreadySubmittedClip && duplicateClipRule.Enabled)
{
await riskFlagService.AddIfMissingAsync(
season.Id,
submitterId,
"clip",
"duplicate_clip_submission",
duplicateClipRule.Severity,
"Ein User hat denselben Clip erneut eingereicht.",
requestMetadata,
new { clipId = clip.Id, clipUrl, CategoryId = resolvedCategoryId, CandidateId = selectedCandidate?.Id, entityLinks = new[] { clipLink } },
context.RequestAborted);
}
if (rapidClipBurstRule.Enabled && recentClipSubmissions >= rapidClipBurstRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
season.Id,
submitterId,
"clip",
"rapid_clip_burst",
rapidClipBurstRule.Severity,
"Ungewoehnlich viele Clip-Einreichungen in kurzer Zeit erkannt.",
requestMetadata,
new { clipId = clip.Id, recentClipSubmissions, threshold = rapidClipBurstRule.Threshold, windowMinutes = rapidClipBurstRule.WindowMinutes, entityLinks = new[] { clipLink } },
context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, clipId = clip.Id });
}
}
+208
View File
@@ -0,0 +1,208 @@
using Backend.Common;
using Backend.Domain;
using Backend.Services;
using Microsoft.AspNetCore.Http.Extensions;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private readonly record struct SubmitterIdResolution(string? SubmitterId, IResult? Result);
private readonly record struct PublicWriteSeasonResolution(Season? Season, IResult? Result);
private sealed record PublicCandidateClip(
int? CategoryId,
int? CandidateId,
string Creator,
string ClipUrl,
string Title,
string Platform,
DateTimeOffset CreatedAt,
DateTimeOffset? ReviewedAt);
private static async Task<SubmitterIdResolution> ResolveSubmitterIdAsync(
HttpContext context,
string? fallbackTwitchUserId,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return new SubmitterIdResolution(
null,
Results.Json(
new { message = "A logged in user is required to submit this action." },
statusCode: StatusCodes.Status401Unauthorized));
}
var submittedTwitchUserId = NormalizeSubmittedTwitchUserId(fallbackTwitchUserId);
if (submittedTwitchUserId is not null
&& !string.Equals(submittedTwitchUserId, session.TwitchUserId, StringComparison.OrdinalIgnoreCase))
{
return new SubmitterIdResolution(
null,
Results.BadRequest(new { message = "Submitted user identity does not match the active session." }));
}
return new SubmitterIdResolution(session.TwitchUserId, null);
}
private static string? NormalizeSubmittedTwitchUserId(string? twitchUserId)
{
var normalized = twitchUserId?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static PublicWriteSeasonResolution EnsurePublicWriteSeason(
Season? season,
params string[] allowedPhaseKeys)
{
if (season is null)
{
return new PublicWriteSeasonResolution(null, Results.BadRequest(new { message = "The selected season does not exist." }));
}
if (!season.IsCurrent)
{
return new PublicWriteSeasonResolution(
null,
Results.BadRequest(new { message = "Submissions are only allowed for the active season." }));
}
var currentPhaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
if (!allowedPhaseKeys.Contains(currentPhaseKey, StringComparer.OrdinalIgnoreCase))
{
var phaseLabel = DescribePublicPhase(currentPhaseKey);
return new PublicWriteSeasonResolution(
null,
Results.BadRequest(new
{
message = $"This action is not available during the current season phase ({phaseLabel}).",
}));
}
return new PublicWriteSeasonResolution(season, null);
}
private static bool TryNormalizeExternalUrl(string? rawUrl, out string normalizedUrl)
{
normalizedUrl = string.Empty;
if (string.IsNullOrWhiteSpace(rawUrl))
{
return false;
}
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
{
return false;
}
if (uri.Scheme is not ("http" or "https"))
{
return false;
}
normalizedUrl = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
return true;
}
private static string? ResolveClipPlatform(string clipUrl)
{
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri))
{
return null;
}
var host = uri.Host.ToLowerInvariant();
if (host is "twitch.tv" or "www.twitch.tv" or "clips.twitch.tv")
{
return "Twitch";
}
if (host is "youtube.com" or "www.youtube.com" or "m.youtube.com" or "youtu.be")
{
return "YouTube";
}
return null;
}
private static bool ShouldExposePublicCategory(string phaseKey, int candidateCount) =>
string.Equals(phaseKey, "nomination", StringComparison.OrdinalIgnoreCase) || candidateCount > 0;
private static Dictionary<int, PublicCandidateClip> BuildCandidateClipLookup(IEnumerable<PublicCandidateClip> clips) =>
clips
.Where(clip => clip.CandidateId is not null)
.GroupBy(clip => clip.CandidateId!.Value)
.ToDictionary(
grouping => grouping.Key,
grouping => grouping
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
.First());
private static Dictionary<string, PublicCandidateClip> BuildCreatorClipLookup(IEnumerable<PublicCandidateClip> clips) =>
clips
.Where(clip => clip.CategoryId is not null && !string.IsNullOrWhiteSpace(clip.Creator))
.GroupBy(clip => BuildCandidateClipLookupKey(clip.CategoryId!.Value, clip.Creator))
.Where(grouping => !string.IsNullOrWhiteSpace(grouping.Key))
.ToDictionary(
grouping => grouping.Key,
grouping => grouping
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
.First());
private static PublicCandidateClip? ResolveCandidateClip(
Candidate candidate,
IReadOnlyDictionary<int, PublicCandidateClip> clipsByCandidateId,
IReadOnlyDictionary<string, PublicCandidateClip> clipsByCreatorKey)
{
if (clipsByCandidateId.TryGetValue(candidate.Id, out var directClip))
{
return directClip;
}
foreach (var key in BuildCandidateClipLookupKeys(candidate))
{
if (clipsByCreatorKey.TryGetValue(key, out var fallbackClip))
{
return fallbackClip;
}
}
return null;
}
private static IEnumerable<string> BuildCandidateClipLookupKeys(Candidate candidate)
{
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.DisplayName);
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug);
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug.TrimStart('@'));
}
private static string BuildCandidateClipLookupKey(int categoryId, string value)
{
var key = NormalizeCandidateClipKey(value);
return string.IsNullOrWhiteSpace(key) ? string.Empty : $"{categoryId}:{key}";
}
private static string NormalizeCandidateClipKey(string value)
{
var normalizedCharacters = value
.Trim()
.TrimStart('@')
.ToLowerInvariant()
.Where(char.IsLetterOrDigit)
.ToArray();
return new string(normalizedCharacters);
}
private static string DescribePublicPhase(string phaseKey) =>
phaseKey switch
{
"nomination" => "nomination",
"voting" => "voting",
"review" => "review",
"show" => "show",
_ => "current",
};
}
+46
View File
@@ -0,0 +1,46 @@
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
public static IEndpointRouteBuilder MapPublicEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/public");
group.MapGet("/overview", GetOverview)
.WithName("GetOverview")
.WithOpenApi();
group.MapGet("/site-status", GetSiteStatus)
.WithName("GetSiteStatus")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
.WithName("GetSeasonCategories")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/winners", GetWinnerArchive)
.WithName("GetWinnerArchive")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
.WithName("GetUserParticipation")
.WithOpenApi();
group.MapPost("/nominations", CreateNomination)
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
.WithName("CreateNomination")
.WithOpenApi();
group.MapPost("/votes", CreateVote)
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
.WithName("CreateVote")
.WithOpenApi();
group.MapPost("/clips", CreateClip)
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
.WithName("CreateClip")
.WithOpenApi();
return app;
}
}
@@ -0,0 +1,181 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> CreateNomination(
HttpContext context,
CreateNominationRequest request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
var submittedNominations = NormalizeSubmittedNominations(request);
if (submittedNominations.Length is 0 or > 3)
{
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." });
}
if (submittedNominations.Any(item => item.Name.Length > 120))
{
return Results.BadRequest(new { message = "Nominee names must stay below 120 characters." });
}
if (submittedNominations.Any(item => item.StreamUrl.Length > 300))
{
return Results.BadRequest(new { message = "Stream links must stay below 300 characters." });
}
if (request.Nominations is { Length: > 0 } && submittedNominations.Any(item => string.IsNullOrWhiteSpace(item.StreamUrl)))
{
return Results.BadRequest(new { message = "A stream link is required for every nomination." });
}
var distinctNomineeNames = submittedNominations
.Select(item => item.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (distinctNomineeNames.Length != submittedNominations.Length)
{
return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." });
}
var invalidStreamUrl = submittedNominations
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
.Select(item => item.StreamUrl)
.FirstOrDefault(item => !TryNormalizeExternalUrl(item, out _));
if (invalidStreamUrl is not null)
{
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
}
var category = await db.Categories
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
if (category is null)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
}
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
if (nominationSeasonResolution.Result is not null)
{
return nominationSeasonResolution.Result;
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
}
var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context);
var existingNominationCount = await db.Nominations.CountAsync(item =>
item.SeasonId == category.SeasonId
&& item.CategoryId == category.Id
&& item.SubmittedByTwitchId == submitterId
&& item.Status == "pending");
var records = submittedNominations.Select(nomination => new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
SubmittedByTwitchId = submitterId,
CandidateText = nomination.Name,
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
Status = "pending",
ReviewNote = string.IsNullOrWhiteSpace(nomination.StreamUrl)
? null
: $"Stream-Link: {nomination.StreamUrl}",
CreatedAt = DateTimeOffset.UtcNow,
}).ToArray();
await db.Nominations.AddRangeAsync(records);
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
var rapidNominationBurstRule = await riskRuleService.GetRuleAsync("rapid_nomination_burst", context.RequestAborted);
var recentNominationVolume = await db.Nominations.CountAsync(item =>
item.SubmittedByTwitchId == submitterId
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidNominationBurstRule.WindowMinutes));
await db.SaveChangesAsync(context.RequestAborted);
var reviewLink = new
{
label = "Review-Fälle öffnen",
entityType = "nomination",
entityId = string.Join(",", records.Select(item => item.Id)),
to = $"/admin/reviews?query={Uri.EscapeDataString(submitterId)}",
};
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
{
await riskFlagService.AddIfMissingAsync(
category.SeasonId,
submitterId,
"nomination",
"resubmitted_nomination",
resubmittedNominationRule.Severity,
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
requestMetadata,
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
context.RequestAborted);
}
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
category.SeasonId,
submitterId,
"nomination",
"rapid_nomination_burst",
rapidNominationBurstRule.Severity,
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
requestMetadata,
new { recentNominationVolume, threshold = rapidNominationBurstRule.Threshold, windowMinutes = rapidNominationBurstRule.WindowMinutes, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
}
private readonly record struct SubmittedNomination(string Name, string StreamUrl);
private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request)
{
if (request.Nominations is { Length: > 0 })
{
return request.Nominations
.Select(item =>
{
var name = item.Name?.Trim() ?? string.Empty;
var streamUrl = item.StreamUrl?.Trim() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
{
streamUrl = normalizedUrl;
}
return new SubmittedNomination(name, streamUrl);
})
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.ToArray();
}
return (request.Nominees ?? [])
.Select(item => new SubmittedNomination(item.Trim(), string.Empty))
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.ToArray();
}
}
@@ -0,0 +1,99 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetOverview(AwardsDbContext db)
{
var siteSettings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
var season = await db.Seasons
.AsNoTracking()
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
.ThenInclude(category => category.Candidates)
.FirstOrDefaultAsync(item => item.IsCurrent);
if (season is null)
{
return Results.NotFound();
}
if (siteSettings is null)
{
return Results.Problem("Site settings are missing.");
}
var winnerPreviewRows = await db.Results
.AsNoTracking()
.Include(result => result.Season)
.Include(result => result.Candidate)
.Where(result => result.Season.Year < season.Year)
.OrderByDescending(result => result.Season.Year)
.ThenBy(result => result.CategoryName)
.Take(8)
.Select(result => new
{
Year = result.Season.Year,
result.CategoryName,
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
WinnerPlatform = result.Candidate.Platform,
})
.ToArrayAsync();
var winnerPreviewItems = winnerPreviewRows
.Select(result => new WinnerPreviewDto(
result.Year,
result.CategoryName,
result.WinnerName,
result.WinnerSlug,
result.WinnerPlatform,
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
.ToArray();
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
var publicCategories = season.Categories
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
.ToArray();
var response = new OverviewResponse(
season.Id,
season.Year,
season.Name,
season.ShowDate,
season.ShowStartsAt,
SeasonMappings.NormalizeSeasonStreamUrl(season.ShowStreamUrl),
season.CurrentPhase,
season.IsCommunityOnly,
"Twitch",
new[]
{
new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, SeasonMappings.ResolveTimelineState("nomination", phaseKey)),
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, SeasonMappings.ResolveTimelineState("voting", phaseKey)),
new TimelineItem("review", "Review & Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("review", phaseKey)),
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
},
publicCategories
.Select(category => new FeaturedCategoryDto(
category.Id,
category.GroupName,
category.Name,
category.Description,
category.MaxNomineesPerUser))
.ToArray(),
winnerPreviewItems,
new PublicSiteContentDto(
siteSettings.HostDisplayName,
siteSettings.HostTagline,
siteSettings.NewsletterUrl,
siteSettings.PrivacyEmail,
siteSettings.PrivacyPolicyContent,
SeasonMappings.ReadSocialLinks(siteSettings),
SeasonMappings.BuildFooterLinks(siteSettings)),
SeasonMappings.ReadFaqItems(siteSettings));
return Results.Ok(response);
}
}
@@ -0,0 +1,71 @@
using Backend.Contracts;
using Backend.Data;
using Backend.Common;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetSeasonCategories(int year, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
.ThenInclude(category => category.Candidates.OrderBy(candidate => candidate.DisplayName))
.FirstOrDefaultAsync(item => item.Year == year);
if (season is null)
{
return Results.NotFound();
}
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
var publicCategories = season.Categories
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
.ToArray();
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
var approvedClips = await db.ClipSubmissions
.AsNoTracking()
.Where(item =>
item.SeasonId == season.Id
&& item.Status == "approved"
&& item.CategoryId != null
&& publicCategoryIds.Contains(item.CategoryId.Value))
.Select(item => new PublicCandidateClip(
item.CategoryId,
item.CandidateId,
item.Creator,
item.ClipUrl,
item.Title,
item.Platform,
item.CreatedAt,
item.ReviewedAt))
.ToArrayAsync();
var clipsByCandidateId = BuildCandidateClipLookup(approvedClips);
var clipsByCreatorKey = BuildCreatorClipLookup(approvedClips);
return Results.Ok(new SeasonCategoriesResponse(
season.Id,
season.Year,
publicCategories.Select(category => new PublicCategoryDetailDto(
category.Id,
category.Name,
category.GroupName,
category.Description,
category.MaxNomineesPerUser,
category.Candidates.Select(candidate =>
{
var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey);
return new CandidateSummaryDto(
candidate.Id,
candidate.DisplayName,
candidate.ChannelSlug,
candidate.Platform,
clip?.ClipUrl,
clip?.Title,
clip?.Platform);
}).ToArray()))
.ToArray()));
}
}
@@ -0,0 +1,53 @@
using Backend.Contracts;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetSiteStatus(AwardsDbContext db, IConfiguration configuration)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.Ok(new PublicSiteStatusResponse(
IsDemoLoginEnabled(configuration),
false,
"Sternenpause",
"Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."));
}
return Results.Ok(new PublicSiteStatusResponse(
ResolveDemoLoginEnabled(settings, configuration),
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? "Sternenpause" : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
? "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."
: settings.MaintenanceMessage));
}
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
{
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
if (!usesDatabaseDemo)
{
return IsDemoLoginEnabled(configuration);
}
return settings.DemoLoginEnabled && HasDatabaseDemoCredentials(settings);
}
private static bool IsDemoLoginEnabled(IConfiguration configuration)
{
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
?? configuration["DemoAdmin:Enabled"];
return bool.TryParse(rawValue, out var enabled) && enabled;
}
private static bool HasDatabaseDemoCredentials(Backend.Domain.SiteSettings settings) =>
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
}
@@ -0,0 +1,89 @@
using Backend.Contracts;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetUserParticipation(
HttpContext context,
int year,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Year == year);
if (season is null)
{
return Results.NotFound();
}
var nominations = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId)
.OrderBy(item => item.CategoryId)
.ThenBy(item => item.Id)
.Select(item => new
{
item.CategoryId,
item.Status,
Nominee = item.CandidateId != null
? item.Candidate!.DisplayName
: item.CandidateText,
})
.ToArrayAsync();
var groupedNominations = nominations
.Where(item => item.Status != "rejected" && item.Status != "superseded")
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
.GroupBy(item => item.CategoryId)
.Select(group => new UserNominationStateDto(
group.Key,
group.Select(item => item.Nominee!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()))
.ToArray();
var votes = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Ballot.SeasonId == season.Id && item.Ballot.SubmittedByTwitchId == session.TwitchUserId)
.OrderBy(item => item.CategoryId)
.Select(item => new UserVoteStateDto(item.CategoryId, item.CandidateId))
.ToArrayAsync();
var clips = await db.ClipSubmissions
.AsNoTracking()
.Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId)
.OrderByDescending(item => item.CreatedAt)
.Take(12)
.Select(item => new UserClipSubmissionStateDto(
item.Id,
item.CategoryId,
item.ClipUrl,
item.Title,
item.Creator,
item.Platform,
item.Status,
item.CreatedAt,
item.ReviewNote,
item.ReviewedAt))
.ToArrayAsync();
return Results.Ok(new UserParticipationResponse(
season.Id,
season.Year,
groupedNominations,
votes,
clips));
}
}
+145
View File
@@ -0,0 +1,145 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> CreateVote(
HttpContext context,
CreateVoteRequest request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
if (request.Entries.Length == 0)
{
return Results.BadRequest(new { message = "At least one vote entry is required." });
}
var distinctCategoryCount = request.Entries
.Select(item => item.CategoryId)
.Distinct()
.Count();
if (distinctCategoryCount != request.Entries.Length)
{
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
}
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == request.SeasonId);
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
if (voteSeasonResolution.Result is not null)
{
return voteSeasonResolution.Result;
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
}
var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context);
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
var validCandidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id))
.Select(item => new { item.Id, item.CategoryId })
.ToArrayAsync();
if (validCandidates.Length != candidateIds.Length)
{
return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." });
}
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
{
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
}
var ballot = await db.VoteBallots
.Include(item => item.Entries)
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null;
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = request.Entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
item.SubmittedByTwitchId == submitterId
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
await db.SaveChangesAsync(context.RequestAborted);
var ballotLink = new
{
label = "Voting-Analytics öffnen",
entityType = "vote",
entityId = ballot.Id.ToString(),
to = $"/admin/analytics?query={Uri.EscapeDataString(submitterId)}",
};
if (isResubmission && resubmittedBallotRule.Enabled)
{
await riskFlagService.AddIfMissingAsync(
request.SeasonId,
submitterId,
"vote",
"resubmitted_ballot",
resubmittedBallotRule.Severity,
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
requestMetadata,
new { ballotId = ballot.Id, entryCount = request.Entries.Length, entityLinks = new[] { ballotLink } },
context.RequestAborted);
}
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
request.SeasonId,
submitterId,
"vote",
"rapid_vote_updates",
rapidVoteUpdatesRule.Severity,
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
requestMetadata,
new { ballotId = ballot.Id, recentVoteSubmissions, threshold = rapidVoteUpdatesRule.Threshold, windowMinutes = rapidVoteUpdatesRule.WindowMinutes, entityLinks = new[] { ballotLink } },
context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
}
}
@@ -0,0 +1,37 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
{
var winnerRows = await db.Results
.AsNoTracking()
.Include(result => result.Candidate)
.Where(result => result.Season.Year == year)
.OrderBy(result => result.CategoryName)
.Select(result => new
{
result.CategoryName,
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
WinnerPlatform = result.Candidate.Platform,
})
.ToArrayAsync();
var items = winnerRows
.Select(result => new WinnerArchiveItemDto(
result.CategoryName,
result.WinnerName,
result.WinnerSlug,
result.WinnerPlatform,
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
.ToArray();
return Results.Ok(new WinnerArchiveResponse(year, items));
}
}
+50
View File
@@ -0,0 +1,50 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class SystemEndpoints
{
public static IEndpointRouteBuilder MapSystemEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" }))
.WithName("GetHealth")
.WithOpenApi();
app.MapGet("/api/health/database", async (AwardsDbContext db, IConfiguration configuration) =>
{
var source = configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings";
try
{
var canConnect = await db.Database.CanConnectAsync();
var pendingMigrations = canConnect
? await db.Database.GetPendingMigrationsAsync()
: Array.Empty<string>();
return Results.Ok(new
{
provider = "postgres",
canConnect,
pendingMigrations,
configuredConnection = new { source },
});
}
catch (Exception exception)
{
return Results.Ok(new
{
provider = "postgres",
canConnect = false,
pendingMigrations = Array.Empty<string>(),
configuredConnection = new { source },
error = exception.Message,
});
}
})
.WithName("GetDatabaseHealth")
.WithOpenApi();
return app;
}
}
@@ -0,0 +1,141 @@
using System.Threading.RateLimiting;
using Backend.Common;
using Backend.Configuration;
using Backend.Data;
using Backend.Repositories;
using Backend.Security;
using Backend.Services;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
namespace Backend.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(
this IServiceCollection services,
IConfiguration configuration,
IWebHostEnvironment environment)
{
services.AddProblemDetails();
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.Configure<FrontendOptions>(configuration.GetSection(FrontendOptions.SectionName));
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"No PostgreSQL connection string configured. Set VTSA_POSTGRES or ConnectionStrings:Postgres.");
}
services.AddCors(options =>
{
options.AddPolicy(ApplicationDefaults.FrontendCorsPolicy, policy =>
{
policy.WithOrigins(allowedOrigins)
.WithHeaders("Authorization", "Content-Type")
.WithMethods(HttpMethods.Get, HttpMethods.Post, HttpMethods.Put, HttpMethods.Delete);
});
});
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.ContentType = "application/json";
await context.HttpContext.Response.WriteAsJsonAsync(
new { message = "Zu viele Anfragen. Bitte kurz warten und erneut versuchen." },
cancellationToken);
};
options.AddPolicy(ApplicationDefaults.AuthRateLimitPolicy, context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: BuildRateLimitPartitionKey(context, "auth"),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true,
}));
options.AddPolicy(ApplicationDefaults.PublicWriteRateLimitPolicy, context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: BuildRateLimitPartitionKey(context, "public-write"),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true,
}));
});
services.AddDbContext<AwardsDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IUserSessionRepository, UserSessionRepository>();
services.AddScoped<IRiskFlagRepository, RiskFlagRepository>();
services.AddScoped<IAdminAuditRepository, AdminAuditRepository>();
services.AddScoped<IUserSessionService, UserSessionService>();
services.AddScoped<IRiskRuleService, RiskRuleService>();
services.AddScoped<IRiskFlagService, RiskFlagService>();
services.AddScoped<IAdminAuditService, AdminAuditService>();
services.AddScoped<AdminSessionFilter>();
return services;
}
private static string BuildRateLimitPartitionKey(HttpContext context, string policyName)
{
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
var route = context.Request.Path.Value ?? "/";
return string.Create(
CultureInfo.InvariantCulture,
$"{policyName}:{ipAddress}:{route}");
}
private static string[] ResolveAllowedOrigins(IConfiguration configuration, IWebHostEnvironment environment)
{
var frontendOptions = configuration.GetSection(FrontendOptions.SectionName).Get<FrontendOptions>();
var configuredOrigins = frontendOptions?.AllowedOrigins
.Select(NormalizeCorsOrigin)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
if (configuredOrigins.Length > 0)
{
return configuredOrigins;
}
if (environment.IsDevelopment())
{
return ApplicationDefaults.FrontendOrigins;
}
throw new InvalidOperationException(
"Frontend:AllowedOrigins must be configured in non-development environments.");
}
private static string NormalizeCorsOrigin(string origin)
{
var trimmedOrigin = origin.Trim();
if (string.IsNullOrWhiteSpace(trimmedOrigin) || trimmedOrigin.Contains('*', StringComparison.Ordinal))
{
throw new InvalidOperationException("CORS origins must be explicit http(s) origins. Wildcards are not allowed.");
}
if (!Uri.TryCreate(trimmedOrigin, UriKind.Absolute, out var uri)
|| uri.Scheme is not ("http" or "https")
|| string.IsNullOrWhiteSpace(uri.Host))
{
throw new InvalidOperationException($"Invalid CORS origin configured: {trimmedOrigin}");
}
return uri.GetLeftPart(UriPartial.Authority);
}
}
@@ -0,0 +1,84 @@
using Backend.Common;
using Backend.Data;
using Backend.Endpoints;
using Backend.Security;
using Microsoft.EntityFrameworkCore;
namespace Backend.Extensions;
public static class WebApplicationExtensions
{
public static void UseApplicationPipeline(this WebApplication app)
{
app.UseExceptionHandler();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(ApplicationDefaults.FrontendCorsPolicy);
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseRateLimiter();
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
}
public static async Task InitializeDatabaseAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AwardsDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("DatabaseInitialization");
try
{
if (app.Environment.IsDevelopment())
{
await db.Database.MigrateAsync();
}
await SessionBootstrapper.EnsureAsync(db);
await OperationalTablesBootstrapper.EnsureAsync(db);
if (ShouldSeedPresentationData(app))
{
await SeedDataBootstrapper.EnsureAsync(db);
}
}
catch (Exception error)
{
logger.LogError(error, "Database initialization failed. Check the PostgreSQL connection, migrations, and seed data.");
throw;
}
}
public static void MapApplicationEndpoints(this WebApplication app)
{
app.MapSystemEndpoints();
app.MapAuthEndpoints();
app.MapPublicEndpoints();
app.MapAdminEndpoints();
}
private static bool ShouldSeedPresentationData(WebApplication app)
{
var mode = app.Configuration["VTSA_SEED_MODE"]
?? app.Configuration["SeedData:Mode"];
if (string.IsNullOrWhiteSpace(mode))
{
return app.Environment.IsDevelopment();
}
return mode.Trim().ToLowerInvariant() is "demo" or "presentation" or "sample";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddClipReviewWorkflow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
ALTER TABLE IF EXISTS "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
ALTER TABLE IF EXISTS "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "ClipSubmissions"
DROP COLUMN IF EXISTS "ReviewNote";
ALTER TABLE IF EXISTS "ClipSubmissions"
DROP COLUMN IF EXISTS "ReviewedByTwitchId";
ALTER TABLE IF EXISTS "ClipSubmissions"
DROP COLUMN IF EXISTS "ReviewedAt";
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddNominationReviewWorkflow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Nominations_SeasonId",
table: "Nominations");
migrationBuilder.AddColumn<string>(
name: "ReviewNote",
table: "Nominations",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ReviewedAt",
table: "Nominations",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ReviewedByTwitchId",
table: "Nominations",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Status",
table: "Nominations",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "Nominations",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" },
values: new object[] { null, null, null, "pending" });
migrationBuilder.UpdateData(
table: "Nominations",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" },
values: new object[] { null, null, null, "pending" });
migrationBuilder.CreateIndex(
name: "IX_Nominations_SeasonId_Status",
table: "Nominations",
columns: new[] { "SeasonId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Nominations_SeasonId_Status",
table: "Nominations");
migrationBuilder.DropColumn(
name: "ReviewNote",
table: "Nominations");
migrationBuilder.DropColumn(
name: "ReviewedAt",
table: "Nominations");
migrationBuilder.DropColumn(
name: "ReviewedByTwitchId",
table: "Nominations");
migrationBuilder.DropColumn(
name: "Status",
table: "Nominations");
migrationBuilder.CreateIndex(
name: "IX_Nominations_SeasonId",
table: "Nominations",
column: "SeasonId");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddAwardResultCategoryLock : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Results_SeasonId",
table: "Results");
migrationBuilder.AddColumn<int>(
name: "CategoryId",
table: "Results",
type: "integer",
nullable: true);
migrationBuilder.Sql(
"""
UPDATE "Results" AS r
SET "CategoryId" = c."Id"
FROM "Categories" AS c
WHERE r."SeasonId" = c."SeasonId"
AND lower(trim(r."CategoryName")) = lower(trim(c."Name"));
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "Results" WHERE "CategoryId" IS NULL) THEN
RAISE EXCEPTION 'Could not backfill CategoryId for one or more rows in Results.';
END IF;
END $$;
""");
migrationBuilder.AlterColumn<int>(
name: "CategoryId",
table: "Results",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Results_CategoryId",
table: "Results",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_Results_SeasonId_CategoryId",
table: "Results",
columns: new[] { "SeasonId", "CategoryId" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Results_Categories_CategoryId",
table: "Results",
column: "CategoryId",
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Results_Categories_CategoryId",
table: "Results");
migrationBuilder.DropIndex(
name: "IX_Results_CategoryId",
table: "Results");
migrationBuilder.DropIndex(
name: "IX_Results_SeasonId_CategoryId",
table: "Results");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "Results");
migrationBuilder.CreateIndex(
name: "IX_Results_SeasonId",
table: "Results",
column: "SeasonId");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddSeasonShowStreamUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ShowStreamUrl",
table: "Seasons",
type: "character varying(400)",
maxLength: 400,
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 1,
column: "ShowStreamUrl",
value: "https://twitch.tv/jayuhime");
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 2,
column: "ShowStreamUrl",
value: "https://twitch.tv/jayuhime");
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 3,
column: "ShowStreamUrl",
value: "https://youtube.com/c/Jayuhime");
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 4,
column: "ShowStreamUrl",
value: "https://twitch.tv/jayuhime");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShowStreamUrl",
table: "Seasons");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddSiteSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SiteSettings",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
HostDisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
HostTagline = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
NewsletterUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
PrivacyEmail = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
ImprintUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
ContactUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
SponsorsUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
SocialLinksJson = table.Column<string>(type: "text", nullable: false),
FaqJson = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SiteSettings", x => x.Id);
});
migrationBuilder.InsertData(
table: "SiteSettings",
columns: new[] { "Id", "ContactUrl", "FaqJson", "HostDisplayName", "HostTagline", "ImprintUrl", "NewsletterUrl", "PrivacyEmail", "SocialLinksJson", "SponsorsUrl" },
values: new object[] { 1, "https://vtuber-star-awards.de/kontakt", "[{\"question\":\"Wer darf mitmachen?\",\"answer\":\"Jede:r mit einem Twitch-Account. Einmal einloggen gen\\u00FCgt \\u2013 kein extra Konto, kein Papierkram.\"},{\"question\":\"Wie werden die Gewinner bestimmt?\",\"answer\":\"Komplett durch eure Stimmen. Die Community entscheidet, wer auf die B\\u00FChne darf.\"},{\"question\":\"Kann ich meine Wahl noch \\u00E4ndern?\",\"answer\":\"Ja. Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen anpassen.\"},{\"question\":\"Wann und wo findet die Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und live auf den verkn\\u00FCpften Plattformen \\u00FCbertragen.\"}]", "Jayuhime", "VTuber & Award Host", "https://vtuber-star-awards.de/impressum", "https://vtuber-star-awards.de/newsletter", "datenschutz@vtuber-star-awards.de", "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\"},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\"},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\"},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\"},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\"}]", "https://vtuber-star-awards.de/partner" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SiteSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddPrivacyPolicyContentMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PrivacyPolicyContent",
table: "SiteSettings",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "PrivacyPolicyUpdatedAt",
table: "SiteSettings",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PrivacyPolicyUpdatedBy",
table: "SiteSettings",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "PrivacyPolicyContent", "PrivacyPolicyUpdatedAt", "PrivacyPolicyUpdatedBy" },
values: new object[] { "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.", new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "seed" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PrivacyPolicyContent",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "PrivacyPolicyUpdatedAt",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "PrivacyPolicyUpdatedBy",
table: "SiteSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddOperationalSiteSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DemoLoginDisplayName",
table: "SiteSettings",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "DemoLoginEmail",
table: "SiteSettings",
type: "character varying(180)",
maxLength: 180,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "DemoLoginEnabled",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "DemoLoginManagedByDatabase",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "DemoLoginPasswordHash",
table: "SiteSettings",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "DemoLoginPasswordSalt",
table: "SiteSettings",
type: "character varying(80)",
maxLength: 80,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "DemoLoginTwitchUserId",
table: "SiteSettings",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "MaintenanceMessage",
table: "SiteSettings",
type: "character varying(600)",
maxLength: 600,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "MaintenanceModeEnabled",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "MaintenanceTitle",
table: "SiteSettings",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "DemoLoginDisplayName", "DemoLoginEmail", "DemoLoginEnabled", "DemoLoginManagedByDatabase", "DemoLoginPasswordHash", "DemoLoginPasswordSalt", "DemoLoginTwitchUserId", "MaintenanceMessage", "MaintenanceModeEnabled", "MaintenanceTitle" },
values: new object[] { "Jayuhime Admin", "", false, false, "", "", "jayuhime_admin", "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", false, "Sternenpause" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DemoLoginDisplayName",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "DemoLoginEmail",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "DemoLoginEnabled",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "DemoLoginManagedByDatabase",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "DemoLoginPasswordHash",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "DemoLoginPasswordSalt",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "DemoLoginTwitchUserId",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "MaintenanceMessage",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "MaintenanceModeEnabled",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "MaintenanceTitle",
table: "SiteSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddSeasonShowStartsAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "Seasons"
ADD COLUMN IF NOT EXISTS "ShowStartsAt" time without time zone NOT NULL DEFAULT TIME '20:00:00';
""");
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 1,
column: "ShowStartsAt",
value: new TimeOnly(20, 0, 0));
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 2,
column: "ShowStartsAt",
value: new TimeOnly(20, 0, 0));
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 3,
column: "ShowStartsAt",
value: new TimeOnly(20, 0, 0));
migrationBuilder.UpdateData(
table: "Seasons",
keyColumn: "Id",
keyValue: 4,
column: "ShowStartsAt",
value: new TimeOnly(20, 0, 0));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShowStartsAt",
table: "Seasons");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddClipCandidateLink : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "CandidateId" integer NULL;
DO $$
BEGIN
IF to_regclass('"ClipSubmissions"') IS NOT NULL THEN
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
ON "ClipSubmissions" ("CandidateId");
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId'
) THEN
ALTER TABLE "ClipSubmissions"
ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId"
FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id")
ON DELETE SET NULL;
END IF;
END IF;
END $$;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "ClipSubmissions"
DROP CONSTRAINT IF EXISTS "FK_ClipSubmissions_Candidates_CandidateId";
DROP INDEX IF EXISTS "IX_ClipSubmissions_CandidateId";
ALTER TABLE IF EXISTS "ClipSubmissions"
DROP COLUMN IF EXISTS "CandidateId";
""");
}
}
}
@@ -0,0 +1,34 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AwardsDbContext))]
[Migration("20260624150500_AddRiskFlagReviewNote")]
public partial class AddRiskFlagReviewNote : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "RiskFlags"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "RiskFlags"
DROP COLUMN IF EXISTS "ReviewNote";
""");
}
}
}
@@ -0,0 +1,39 @@
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AwardsDbContext))]
[Migration("20260624162000_AddRiskRulesJson")]
public partial class AddRiskRulesJson : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
$"""
ALTER TABLE IF EXISTS "SiteSettings"
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '{RiskRuleSettings.Serialize(RiskRuleSettings.Defaults).Replace("'", "''")}';
UPDATE "SiteSettings"
SET "RiskRulesJson" = '{RiskRuleSettings.Serialize(RiskRuleSettings.Defaults).Replace("'", "''")}'
WHERE "RiskRulesJson" IS NULL OR btrim("RiskRulesJson") = '' OR "RiskRulesJson" = '[]';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "SiteSettings"
DROP COLUMN IF EXISTS "RiskRulesJson";
""");
}
}
}
@@ -22,6 +22,51 @@ namespace Backend.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ActionType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("AdminTwitchUserId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("MetadataJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.HasKey("Id");
b.ToTable("AdminAuditEntries");
});
modelBuilder.Entity("Backend.Domain.AwardResult", b => modelBuilder.Entity("Backend.Domain.AwardResult", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -33,6 +78,9 @@ namespace Backend.Migrations
b.Property<int>("CandidateId") b.Property<int>("CandidateId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<string>("CategoryName") b.Property<string>("CategoryName")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -45,7 +93,10 @@ namespace Backend.Migrations
b.HasIndex("CandidateId"); b.HasIndex("CandidateId");
b.HasIndex("SeasonId"); b.HasIndex("CategoryId");
b.HasIndex("SeasonId", "CategoryId")
.IsUnique();
b.ToTable("Results"); b.ToTable("Results");
@@ -54,6 +105,7 @@ namespace Backend.Migrations
{ {
Id = 1, Id = 1,
CandidateId = 8, CandidateId = 8,
CategoryId = 5,
CategoryName = "VTuber des Jahres", CategoryName = "VTuber des Jahres",
SeasonId = 2 SeasonId = 2
}, },
@@ -61,6 +113,7 @@ namespace Backend.Migrations
{ {
Id = 2, Id = 2,
CandidateId = 9, CandidateId = 9,
CategoryId = 6,
CategoryName = "Bestes Live Event", CategoryName = "Bestes Live Event",
SeasonId = 2 SeasonId = 2
}, },
@@ -68,6 +121,7 @@ namespace Backend.Migrations
{ {
Id = 3, Id = 3,
CandidateId = 10, CandidateId = 10,
CategoryId = 7,
CategoryName = "Clip des Jahres", CategoryName = "Clip des Jahres",
SeasonId = 2 SeasonId = 2
}, },
@@ -75,6 +129,7 @@ namespace Backend.Migrations
{ {
Id = 4, Id = 4,
CandidateId = 11, CandidateId = 11,
CategoryId = 8,
CategoryName = "VTuber des Jahres", CategoryName = "VTuber des Jahres",
SeasonId = 3 SeasonId = 3
}, },
@@ -82,6 +137,7 @@ namespace Backend.Migrations
{ {
Id = 5, Id = 5,
CandidateId = 12, CandidateId = 12,
CategoryId = 9,
CategoryName = "Clip des Jahres", CategoryName = "Clip des Jahres",
SeasonId = 3 SeasonId = 3
}, },
@@ -89,6 +145,7 @@ namespace Backend.Migrations
{ {
Id = 6, Id = 6,
CandidateId = 13, CandidateId = 13,
CategoryId = 10,
CategoryName = "VTuber des Jahres", CategoryName = "VTuber des Jahres",
SeasonId = 4 SeasonId = 4
}); });
@@ -407,6 +464,81 @@ namespace Backend.Migrations
}); });
}); });
modelBuilder.Entity("Backend.Domain.ClipSubmission", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CandidateId")
.HasColumnType("integer");
b.Property<int?>("CategoryId")
.HasColumnType("integer");
b.Property<string>("ClipUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedFromIp")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Creator")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
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>("SubmittedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CandidateId");
b.HasIndex("SeasonId", "Status");
b.ToTable("ClipSubmissions");
});
modelBuilder.Entity("Backend.Domain.Nomination", b => modelBuilder.Entity("Backend.Domain.Nomination", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -428,9 +560,25 @@ namespace Backend.Migrations
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
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") b.Property<int>("SeasonId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SubmittedByTwitchId") b.Property<string>("SubmittedByTwitchId")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -442,7 +590,7 @@ namespace Backend.Migrations
b.HasIndex("CategoryId"); b.HasIndex("CategoryId");
b.HasIndex("SeasonId"); b.HasIndex("SeasonId", "Status");
b.ToTable("Nominations"); b.ToTable("Nominations");
@@ -454,6 +602,7 @@ namespace Backend.Migrations
CategoryId = 1, CategoryId = 1,
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SeasonId = 1, SeasonId = 1,
Status = "pending",
SubmittedByTwitchId = "twitch_hoshi" SubmittedByTwitchId = "twitch_hoshi"
}, },
new new
@@ -463,10 +612,86 @@ namespace Backend.Migrations
CategoryId = 2, CategoryId = 2,
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SeasonId = 1, SeasonId = 1,
Status = "pending",
SubmittedByTwitchId = "twitch_kurainu" SubmittedByTwitchId = "twitch_kurainu"
}); });
}); });
modelBuilder.Entity("Backend.Domain.RiskFlag", 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>("CreatedFromIp")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("MetadataJson")
.IsRequired()
.HasColumnType("text");
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>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("TwitchUserId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("UserAgent")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.HasKey("Id");
b.HasIndex("SeasonId");
b.ToTable("RiskFlags");
});
modelBuilder.Entity("Backend.Domain.Season", b => modelBuilder.Entity("Backend.Domain.Season", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -506,6 +731,14 @@ namespace Backend.Migrations
b.Property<DateOnly>("ShowDate") b.Property<DateOnly>("ShowDate")
.HasColumnType("date"); .HasColumnType("date");
b.Property<TimeOnly>("ShowStartsAt")
.HasColumnType("time without time zone");
b.Property<string>("ShowStreamUrl")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<DateOnly>("VotingEndsAt") b.Property<DateOnly>("VotingEndsAt")
.HasColumnType("date"); .HasColumnType("date");
@@ -535,6 +768,8 @@ namespace Backend.Migrations
ReviewEndsAt = new DateOnly(2026, 7, 10), ReviewEndsAt = new DateOnly(2026, 7, 10),
ReviewStartsAt = new DateOnly(2026, 7, 1), ReviewStartsAt = new DateOnly(2026, 7, 1),
ShowDate = new DateOnly(2026, 7, 20), ShowDate = new DateOnly(2026, 7, 20),
ShowStartsAt = new TimeOnly(20, 0, 0),
ShowStreamUrl = "https://twitch.tv/jayuhime",
VotingEndsAt = new DateOnly(2026, 6, 30), VotingEndsAt = new DateOnly(2026, 6, 30),
VotingStartsAt = new DateOnly(2026, 6, 1), VotingStartsAt = new DateOnly(2026, 6, 1),
Year = 2026 Year = 2026
@@ -551,6 +786,8 @@ namespace Backend.Migrations
ReviewEndsAt = new DateOnly(2025, 7, 10), ReviewEndsAt = new DateOnly(2025, 7, 10),
ReviewStartsAt = new DateOnly(2025, 7, 1), ReviewStartsAt = new DateOnly(2025, 7, 1),
ShowDate = new DateOnly(2025, 7, 20), ShowDate = new DateOnly(2025, 7, 20),
ShowStartsAt = new TimeOnly(20, 0, 0),
ShowStreamUrl = "https://twitch.tv/jayuhime",
VotingEndsAt = new DateOnly(2025, 6, 30), VotingEndsAt = new DateOnly(2025, 6, 30),
VotingStartsAt = new DateOnly(2025, 6, 1), VotingStartsAt = new DateOnly(2025, 6, 1),
Year = 2025 Year = 2025
@@ -567,6 +804,8 @@ namespace Backend.Migrations
ReviewEndsAt = new DateOnly(2024, 7, 10), ReviewEndsAt = new DateOnly(2024, 7, 10),
ReviewStartsAt = new DateOnly(2024, 7, 1), ReviewStartsAt = new DateOnly(2024, 7, 1),
ShowDate = new DateOnly(2024, 7, 20), ShowDate = new DateOnly(2024, 7, 20),
ShowStartsAt = new TimeOnly(20, 0, 0),
ShowStreamUrl = "https://youtube.com/c/Jayuhime",
VotingEndsAt = new DateOnly(2024, 6, 30), VotingEndsAt = new DateOnly(2024, 6, 30),
VotingStartsAt = new DateOnly(2024, 6, 1), VotingStartsAt = new DateOnly(2024, 6, 1),
Year = 2024 Year = 2024
@@ -583,12 +822,211 @@ namespace Backend.Migrations
ReviewEndsAt = new DateOnly(2023, 7, 10), ReviewEndsAt = new DateOnly(2023, 7, 10),
ReviewStartsAt = new DateOnly(2023, 7, 1), ReviewStartsAt = new DateOnly(2023, 7, 1),
ShowDate = new DateOnly(2023, 7, 20), ShowDate = new DateOnly(2023, 7, 20),
ShowStartsAt = new TimeOnly(20, 0, 0),
ShowStreamUrl = "https://twitch.tv/jayuhime",
VotingEndsAt = new DateOnly(2023, 6, 30), VotingEndsAt = new DateOnly(2023, 6, 30),
VotingStartsAt = new DateOnly(2023, 6, 1), VotingStartsAt = new DateOnly(2023, 6, 1),
Year = 2023 Year = 2023
}); });
}); });
modelBuilder.Entity("Backend.Domain.SiteSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ContactUrl")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("DemoLoginDisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DemoLoginEmail")
.IsRequired()
.HasMaxLength(180)
.HasColumnType("character varying(180)");
b.Property<bool>("DemoLoginEnabled")
.HasColumnType("boolean");
b.Property<bool>("DemoLoginManagedByDatabase")
.HasColumnType("boolean");
b.Property<string>("DemoLoginPasswordHash")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DemoLoginPasswordSalt")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("DemoLoginTwitchUserId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("FaqJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("HostDisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("HostTagline")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("ImprintUrl")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("MaintenanceMessage")
.IsRequired()
.HasMaxLength(600)
.HasColumnType("character varying(600)");
b.Property<bool>("MaintenanceModeEnabled")
.HasColumnType("boolean");
b.Property<string>("MaintenanceTitle")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("NewsletterUrl")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("PrivacyEmail")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("PrivacyPolicyContent")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("PrivacyPolicyUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PrivacyPolicyUpdatedBy")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("RiskRulesJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SocialLinksJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SponsorsUrl")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.HasKey("Id");
b.ToTable("SiteSettings");
b.HasData(
new
{
Id = 1,
ContactUrl = "https://vtuber-star-awards.de/kontakt",
DemoLoginDisplayName = "Jayuhime Admin",
DemoLoginEmail = "",
DemoLoginEnabled = false,
DemoLoginManagedByDatabase = false,
DemoLoginPasswordHash = "",
DemoLoginPasswordSalt = "",
DemoLoginTwitchUserId = "jayuhime_admin",
FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]",
HostDisplayName = "Jayuhime",
HostTagline = "VTuber & Award Host",
ImprintUrl = "https://vtuber-star-awards.de/impressum",
MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.",
MaintenanceModeEnabled = false,
MaintenanceTitle = "Sternenpause",
NewsletterUrl = "https://vtuber-star-awards.de/newsletter",
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 = "[]",
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}]",
SponsorsUrl = "https://vtuber-star-awards.de/partner"
});
});
modelBuilder.Entity("Backend.Domain.UserSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedFromIp")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("SessionToken")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("TwitchUserId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("UserAgent")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.HasKey("Id");
b.HasIndex("SessionToken")
.IsUnique();
b.ToTable("UserSessions");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b => modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -704,6 +1142,12 @@ namespace Backend.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Backend.Domain.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Season", "Season") b.HasOne("Backend.Domain.Season", "Season")
.WithMany("Results") .WithMany("Results")
.HasForeignKey("SeasonId") .HasForeignKey("SeasonId")
@@ -712,6 +1156,8 @@ namespace Backend.Migrations
b.Navigation("Candidate"); b.Navigation("Candidate");
b.Navigation("Category");
b.Navigation("Season"); b.Navigation("Season");
}); });
@@ -745,6 +1191,16 @@ namespace Backend.Migrations
b.Navigation("Season"); b.Navigation("Season");
}); });
modelBuilder.Entity("Backend.Domain.ClipSubmission", b =>
{
b.HasOne("Backend.Domain.Candidate", "Candidate")
.WithMany()
.HasForeignKey("CandidateId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Candidate");
});
modelBuilder.Entity("Backend.Domain.Nomination", b => modelBuilder.Entity("Backend.Domain.Nomination", b =>
{ {
b.HasOne("Backend.Domain.Candidate", "Candidate") b.HasOne("Backend.Domain.Candidate", "Candidate")
@@ -770,6 +1226,15 @@ namespace Backend.Migrations
b.Navigation("Season"); b.Navigation("Season");
}); });
modelBuilder.Entity("Backend.Domain.RiskFlag", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId");
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b => modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{ {
b.HasOne("Backend.Domain.Season", "Season") b.HasOne("Backend.Domain.Season", "Season")
+5 -1329
View File
File diff suppressed because it is too large Load Diff
+48 -9
View File
@@ -4,16 +4,33 @@
The API targets PostgreSQL through EF Core 8 and `Npgsql.EntityFrameworkCore.PostgreSQL`. The API targets PostgreSQL through EF Core 8 and `Npgsql.EntityFrameworkCore.PostgreSQL`.
Default local development connection string: Local development is aligned with `../docker-compose.dev.yml` and `appsettings.Development.json`:
```text ```text
Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only
``` ```
You can override it with: For production or shared deployments, keep checked-in appsettings empty and provide your own value through:
```text
VTSA_POSTGRES
ConnectionStrings__Postgres
```
The API reads its connection string from:
- `Backend/appsettings.Development.json`
- environment variable `VTSA_POSTGRES` - environment variable `VTSA_POSTGRES`
- environment variable `ConnectionStrings__Postgres`
Presentation/demo data is controlled separately:
```text
VTSA_SEED_MODE=demo
```
- `demo`, `presentation` or `sample`: seed local presentation data.
- `none`, `off`, `disabled` or an unset value in Production: do not seed presentation data.
- `Backend/appsettings.Development.json` defaults to `demo`; `Backend/appsettings.json` defaults to `none`.
If Docker is available locally, start a dev database from the repository root with: If Docker is available locally, start a dev database from the repository root with:
@@ -51,20 +68,20 @@ dotnet ef database update
Fallback bootstrap if `dotnet ef` is not usable in the current environment: Fallback bootstrap if `dotnet ef` is not usable in the current environment:
```bash ```bash
psql "Host=localhost Port=5433 Database=vtuber_star_awards_dev Username=postgres Password=postgres" -f Migrations/InitialCreate.manual.sql psql "$VTSA_POSTGRES" -f Migrations/InitialCreate.manual.sql
``` ```
Run the API: Run the API:
```bash ```bash
ASPNETCORE_ENVIRONMENT=Development dotnet run ASPNETCORE_ENVIRONMENT=Development dotnet run --urls http://127.0.0.1:5084
``` ```
Check the API and database wiring: Check the API and database wiring:
```bash ```bash
curl http://localhost:5084/api/health curl http://127.0.0.1:5084/api/health
curl http://localhost:5084/api/health/database curl http://127.0.0.1:5084/api/health/database
``` ```
Development auth/session: Development auth/session:
@@ -72,5 +89,27 @@ Development auth/session:
```bash ```bash
curl -X POST http://localhost:5084/api/auth/dev-login \ curl -X POST http://localhost:5084/api/auth/dev-login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"twitchUserId":"admin_demo","displayName":"Admin Demo","role":"admin"}' -d '{"twitchUserId":"jayuhime_admin","displayName":"Jayuhime Admin","role":"admin"}'
``` ```
Demo admin login for public presentations:
```text
VTSA_DEMO_LOGIN_ENABLED=true
VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin
VTSA_DEMO_ADMIN_EMAIL=admin@example.local
VTSA_DEMO_ADMIN_PASSWORD=<set-secure-demo-password>
VTSA_DEMO_ADMIN_TWITCH_ID=jayuhime_admin
VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin
```
The frontend route is `/login`. `VTSA_DEMO_ADMIN_LOGIN` may be a username or an email-style identifier; the backend also accepts the configured email, Twitch ID, and display name for admin flexibility. Disable the demo login for release with `VTSA_DEMO_LOGIN_ENABLED=false`.
Frontend app-wide demo gate:
```text
VITE_DEMO_GATE_ENABLED=true
```
- `true`: the whole Vue app starts at `/login` until a session exists.
- unset or `false`: public pages such as `/` are visible without the initial demo login.
@@ -0,0 +1,9 @@
using Backend.Data;
using Backend.Domain;
namespace Backend.Repositories;
public sealed class AdminAuditRepository(AwardsDbContext dbContext) : IAdminAuditRepository
{
public void Add(AdminAuditEntry entry) => dbContext.AdminAuditEntries.Add(entry);
}
@@ -0,0 +1,8 @@
using Backend.Domain;
namespace Backend.Repositories;
public interface IAdminAuditRepository
{
void Add(AdminAuditEntry entry);
}
@@ -0,0 +1,17 @@
using Backend.Domain;
namespace Backend.Repositories;
public interface IRiskFlagRepository
{
Task<bool> ExistsOpenRecentAsync(
int? seasonId,
string? twitchUserId,
string source,
string type,
string clientIp,
DateTimeOffset threshold,
CancellationToken cancellationToken = default);
void Add(RiskFlag riskFlag);
}
@@ -0,0 +1,11 @@
using Backend.Domain;
namespace Backend.Repositories;
public interface IUserSessionRepository
{
Task<UserSession?> GetActiveByTokenAsync(string token, CancellationToken cancellationToken = default);
Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
void Add(UserSession session);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

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