Add winner archive, host image upload and live demo data
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s

Deliver the demo-ready feature set and seed data so the live site can be
presented end to end:

- Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager
  and public archive surface, backed by AddArchivedWinners migration.
- Host presentation: host image upload and artist name on SiteSettings with
  public image endpoint and supporting migrations.
- Clip submissions: idempotent table-ensure migration plus current-season
  demo clips for review workflows.
- Demo seed data: sponsors, share links and 2025 archived winners, with a
  guarded RemoveDemoSeasons cleanup; all seeds guard against real data.
- EnsureRuntimeSchemaParity migration to align runtime schema defensively.
- Admin/home UI refinements; remove unused team role permissions modal and
  dead share-quick-links code.

All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT)
and skip when real season data is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-29 21:47:52 +02:00
parent f323e5a82c
commit 6b3b0360e7
92 changed files with 7412 additions and 583 deletions
+44
View File
@@ -92,6 +92,50 @@ public static class SeasonMappings
};
}
public static (string Platform, string Slug) InferProfileMetadataFromUrl(string? value, string fallbackName = "")
{
var trimmed = value?.Trim() ?? string.Empty;
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
{
return ("Profil", fallbackName.Trim());
}
var host = uri.Host.Trim().ToLowerInvariant();
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var platform = host switch
{
var item when item.Contains("twitch.tv", StringComparison.Ordinal) => "Twitch",
var item when item.Contains("youtube.com", StringComparison.Ordinal) || item.Contains("youtu.be", StringComparison.Ordinal) => "YouTube",
var item when item.Contains("x.com", StringComparison.Ordinal) || item.Contains("twitter.com", StringComparison.Ordinal) => "X",
var item when item.Contains("instagram.com", StringComparison.Ordinal) => "Instagram",
var item when item.Contains("discord.gg", StringComparison.Ordinal) || item.Contains("discord.com", StringComparison.Ordinal) => "Discord",
var item when item.Contains("kick.com", StringComparison.Ordinal) => "Kick",
var item when item.Contains("cake.gg", StringComparison.Ordinal) => "Cake",
_ => "Profil",
};
var slug = segments.LastOrDefault() ?? string.Empty;
if (string.Equals(platform, "YouTube", StringComparison.Ordinal) && segments.Length > 0)
{
slug = segments.FirstOrDefault(segment => segment.StartsWith('@')) ?? slug;
}
slug = Uri.UnescapeDataString(slug).Trim().Trim('/');
if (slug.StartsWith('@') && !string.Equals(platform, "YouTube", StringComparison.Ordinal))
{
slug = slug[1..];
}
if (string.IsNullOrWhiteSpace(slug))
{
slug = fallbackName.Trim();
}
return (platform, slug);
}
public static string NormalizeSeasonStreamUrl(string? value)
{
var trimmed = value?.Trim() ?? string.Empty;
@@ -0,0 +1,18 @@
namespace Backend.Contracts;
public sealed record AdminArchivedWinnerItemDto(
int Id,
int Year,
string Category,
string Subcategory,
string WinnerName,
string WinnerUrl,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record UpsertArchivedWinnerRequest(
int Year,
string Category,
string Subcategory,
string WinnerName,
string WinnerUrl);
@@ -37,6 +37,8 @@ public sealed record AdminCandidateItemDto(
string DisplayName,
string ChannelSlug,
string Platform,
int? AvgViewers,
int Votes,
int NominationTally,
string AcceptanceStatus,
string? AcceptanceNote,
@@ -3,6 +3,8 @@ namespace Backend.Contracts;
public sealed record AdminSiteSettingsResponse(
string HostDisplayName,
string HostTagline,
string HostArtistName,
string HostImageUrl,
string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
@@ -41,6 +43,7 @@ public sealed record AdminSiteSettingsResponse(
public sealed record UpdateSiteSettingsRequest(
string HostDisplayName,
string HostTagline,
string HostArtistName,
string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
@@ -64,6 +64,8 @@ public sealed record PublicStreamBannerContentDto(
public sealed record PublicSiteContentDto(
string HostDisplayName,
string HostTagline,
string HostArtistName,
string HostImageUrl,
string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
+13
View File
@@ -10,6 +10,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<Candidate> Candidates => Set<Candidate>();
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
public DbSet<AwardResult> Results => Set<AwardResult>();
public DbSet<ArchivedWinner> ArchivedWinners => Set<ArchivedWinner>();
public DbSet<Nomination> Nominations => Set<Nomination>();
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
@@ -40,6 +41,9 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
{
entity.Property(item => item.HostDisplayName).HasMaxLength(120);
entity.Property(item => item.HostTagline).HasMaxLength(160);
entity.Property(item => item.HostArtistName).HasMaxLength(120);
entity.Property(item => item.HostImageData).HasColumnType("bytea");
entity.Property(item => item.HostImageContentType).HasMaxLength(80);
entity.Property(item => item.NewsletterUrl).HasMaxLength(400);
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
@@ -186,6 +190,15 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.CategoryName).HasMaxLength(120);
});
modelBuilder.Entity<ArchivedWinner>(entity =>
{
entity.HasIndex(item => new { item.Year, item.Category, item.Subcategory }).IsUnique();
entity.Property(item => item.Category).HasMaxLength(120);
entity.Property(item => item.Subcategory).HasMaxLength(120);
entity.Property(item => item.WinnerName).HasMaxLength(120);
entity.Property(item => item.WinnerUrl).HasMaxLength(500);
});
modelBuilder.Entity<UserSession>(entity =>
{
entity.HasIndex(item => item.SessionToken).IsUnique();
+13
View File
@@ -0,0 +1,13 @@
namespace Backend.Domain;
public sealed class ArchivedWinner
{
public int Id { get; set; }
public int Year { get; set; }
public string Category { get; set; } = string.Empty;
public string Subcategory { get; set; } = string.Empty;
public string WinnerName { get; set; } = string.Empty;
public string WinnerUrl { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? UpdatedAt { get; set; }
}
+4
View File
@@ -5,6 +5,10 @@ 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 HostArtistName { get; set; } = string.Empty;
public byte[]? HostImageData { get; set; }
public string? HostImageContentType { get; set; }
public DateTimeOffset? HostImageUpdatedAt { get; set; }
public string NewsletterUrl { get; set; } = string.Empty;
public string ShareXUrl { get; set; } = string.Empty;
public string ShareDiscordUrl { get; set; } = string.Empty;
+249
View File
@@ -0,0 +1,249 @@
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 AdminArchiveEndpoints
{
public static RouteGroupBuilder MapAdminArchiveEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/archived-winners", GetArchivedWinners)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("GetAdminArchivedWinners");
group.MapPost("/archived-winners", CreateArchivedWinner)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("CreateAdminArchivedWinner");
group.MapPut("/archived-winners/{archivedWinnerId:int}", UpdateArchivedWinner)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("UpdateAdminArchivedWinner");
group.MapDelete("/archived-winners/{archivedWinnerId:int}", DeleteArchivedWinner)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("DeleteAdminArchivedWinner");
return group;
}
private static async Task<IResult> GetArchivedWinners(AwardsDbContext db, CancellationToken cancellationToken)
{
var items = await db.ArchivedWinners
.AsNoTracking()
.OrderByDescending(item => item.Year)
.ThenBy(item => item.Category)
.ThenBy(item => item.Subcategory)
.ThenBy(item => item.WinnerName)
.Select(item => ToDto(item))
.ToArrayAsync(cancellationToken);
return Results.Ok(items);
}
private static async Task<IResult> CreateArchivedWinner(
HttpContext context,
UpsertArchivedWinnerRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validation = ValidateRequest(request);
if (validation is not null)
{
return validation;
}
var normalized = NormalizeRequest(request);
var duplicateExists = await db.ArchivedWinners.AnyAsync(item =>
item.Year == normalized.Year
&& item.Category == normalized.Category
&& item.Subcategory == normalized.Subcategory,
context.RequestAborted);
if (duplicateExists)
{
return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." });
}
var archivedWinner = new ArchivedWinner
{
Year = normalized.Year,
Category = normalized.Category,
Subcategory = normalized.Subcategory,
WinnerName = normalized.WinnerName,
WinnerUrl = normalized.WinnerUrl,
};
db.ArchivedWinners.Add(archivedWinner);
adminAuditService.AddEntry(
session.TwitchUserId,
"archived-winner.create",
"archivedWinner",
$"{normalized.Year}:{normalized.Category}:{normalized.Subcategory}",
$"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} angelegt.",
new
{
normalized.Year,
normalized.Category,
normalized.Subcategory,
normalized.WinnerName,
normalized.WinnerUrl,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) });
}
private static async Task<IResult> UpdateArchivedWinner(
HttpContext context,
int archivedWinnerId,
UpsertArchivedWinnerRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validation = ValidateRequest(request);
if (validation is not null)
{
return validation;
}
var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted);
if (archivedWinner is null)
{
return Results.NotFound();
}
var normalized = NormalizeRequest(request);
var duplicateExists = await db.ArchivedWinners.AnyAsync(item =>
item.Id != archivedWinnerId
&& item.Year == normalized.Year
&& item.Category == normalized.Category
&& item.Subcategory == normalized.Subcategory,
context.RequestAborted);
if (duplicateExists)
{
return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." });
}
archivedWinner.Year = normalized.Year;
archivedWinner.Category = normalized.Category;
archivedWinner.Subcategory = normalized.Subcategory;
archivedWinner.WinnerName = normalized.WinnerName;
archivedWinner.WinnerUrl = normalized.WinnerUrl;
archivedWinner.UpdatedAt = DateTimeOffset.UtcNow;
adminAuditService.AddEntry(
session.TwitchUserId,
"archived-winner.update",
"archivedWinner",
archivedWinner.Id.ToString(),
$"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} aktualisiert.",
new
{
archivedWinner.Id,
normalized.Year,
normalized.Category,
normalized.Subcategory,
normalized.WinnerName,
normalized.WinnerUrl,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) });
}
private static async Task<IResult> DeleteArchivedWinner(
HttpContext context,
int archivedWinnerId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted);
if (archivedWinner is null)
{
return Results.NotFound();
}
db.ArchivedWinners.Remove(archivedWinner);
adminAuditService.AddEntry(
session.TwitchUserId,
"archived-winner.delete",
"archivedWinner",
archivedWinner.Id.ToString(),
$"Archivgewinner {archivedWinner.Year} · {archivedWinner.Category} · {archivedWinner.Subcategory} gelöscht.",
new
{
archivedWinner.Id,
archivedWinner.Year,
archivedWinner.Category,
archivedWinner.Subcategory,
archivedWinner.WinnerName,
archivedWinner.WinnerUrl,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, archivedWinnerId });
}
private static AdminArchivedWinnerItemDto ToDto(ArchivedWinner item) =>
new(
item.Id,
item.Year,
item.Category,
item.Subcategory,
item.WinnerName,
item.WinnerUrl,
item.CreatedAt,
item.UpdatedAt);
private static IResult? ValidateRequest(UpsertArchivedWinnerRequest request)
{
if (request.Year < 2000 || request.Year > 3000)
{
return Results.BadRequest(new { message = "Bitte ein gültiges Archivjahr angeben." });
}
if (string.IsNullOrWhiteSpace(request.Category))
{
return Results.BadRequest(new { message = "Die Kategorie darf nicht leer sein." });
}
if (string.IsNullOrWhiteSpace(request.Subcategory))
{
return Results.BadRequest(new { message = "Die Unterkategorie darf nicht leer sein." });
}
if (string.IsNullOrWhiteSpace(request.WinnerName))
{
return Results.BadRequest(new { message = "Der Gewinnername darf nicht leer sein." });
}
var winnerUrl = request.WinnerUrl?.Trim() ?? string.Empty;
if (!Uri.TryCreate(winnerUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return Results.BadRequest(new { message = "Bitte einen gültigen http- oder https-Link angeben." });
}
return null;
}
private static (int Year, string Category, string Subcategory, string WinnerName, string WinnerUrl) NormalizeRequest(UpsertArchivedWinnerRequest request) =>
(
request.Year,
SeasonMappings.NormalizePlainTextContent(request.Category),
SeasonMappings.NormalizePlainTextContent(request.Subcategory),
SeasonMappings.NormalizePlainTextContent(request.WinnerName),
request.WinnerUrl.Trim());
}
+1
View File
@@ -13,6 +13,7 @@ public static class AdminEndpoints
group.MapAdminDashboardEndpoints();
group.MapAdminSeasonManagementEndpoints();
group.MapAdminArchiveEndpoints();
group.MapAdminModerationEndpoints();
group.MapAdminExtrasEndpoints();
group.MapAdminTeamEndpoints();
@@ -28,13 +28,6 @@ public static partial class AdminModerationEndpoints
return Results.NotFound();
}
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
if (trackingFlags.Any(flag => flag.BlocksApproval)
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
}
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
if (string.IsNullOrWhiteSpace(rawDisplayName))
@@ -193,6 +193,34 @@ public static partial class AdminSeasonManagementEndpoints
return Results.Ok(new { saved = true, candidateId = candidate.Id });
}
private static async Task<IResult> GetCandidateDeletePreview(
int candidateId,
AwardsDbContext db,
CancellationToken cancellationToken)
{
var candidate = await db.Candidates
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == candidateId, cancellationToken);
if (candidate is null)
{
return Results.NotFound();
}
var nominationCount = await db.Nominations.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
var clipCount = await db.ClipSubmissions.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
var voteCount = await db.VoteEntries.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
var resultCount = await db.Results.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
return Results.Ok(new
{
candidateId,
nominationCount,
clipCount,
voteCount,
resultCount,
});
}
private static async Task<IResult> DeleteCandidate(
HttpContext context,
int candidateId,
@@ -206,6 +234,38 @@ public static partial class AdminSeasonManagementEndpoints
return Results.NotFound();
}
var linkedNominations = await db.Nominations
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedNominations.Count > 0)
{
db.Nominations.RemoveRange(linkedNominations);
}
var linkedClips = await db.ClipSubmissions
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedClips.Count > 0)
{
db.ClipSubmissions.RemoveRange(linkedClips);
}
var linkedVoteEntries = await db.VoteEntries
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedVoteEntries.Count > 0)
{
db.VoteEntries.RemoveRange(linkedVoteEntries);
}
var linkedResults = await db.Results
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedResults.Count > 0)
{
db.Results.RemoveRange(linkedResults);
}
db.Candidates.Remove(candidate);
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -213,11 +273,29 @@ public static partial class AdminSeasonManagementEndpoints
"candidate",
candidate.Id.ToString(),
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
new { candidate.CategoryId, candidate.Platform },
new
{
candidate.CategoryId,
candidate.Platform,
deletedNominations = linkedNominations.Count,
deletedClips = linkedClips.Count,
deletedVoteEntries = linkedVoteEntries.Count,
deletedResults = linkedResults.Count,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, candidateId });
try
{
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, candidateId });
}
catch (DbUpdateException)
{
return Results.BadRequest(new
{
message = "Kandidat konnte nicht gelöscht werden, weil noch verknüpfte Daten blockieren.",
});
}
}
private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection<string> allowedValues)
@@ -23,11 +23,11 @@ public static partial class AdminSeasonManagementEndpoints
.FirstOrDefaultAsync(item => item.Id == 1);
var trackingRules = TrackingRulesSettings.Read(settings);
var candidates = await db.Candidates
var candidateRows = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.DisplayName)
.Select(item => new AdminCandidateItemDto(
.Select(item => new AdminCandidateRow(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
@@ -43,7 +43,7 @@ public static partial class AdminSeasonManagementEndpoints
item.ClipEmbedStatus))
.ToArrayAsync();
var candidateCounts = candidates
var candidateCounts = candidateRows
.GroupBy(item => item.CategoryId)
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
@@ -217,6 +217,11 @@ public static partial class AdminSeasonManagementEndpoints
item.CategoryId,
item.CandidateId))
.ToArrayAsync();
var candidates = BuildCandidateItems(
candidateRows,
pendingNominationRows,
reviewedNominationRows,
votingEntryRows);
var votingWorkspace = BuildVotingWorkspace(
categories,
candidates,
@@ -281,6 +286,21 @@ public static partial class AdminSeasonManagementEndpoints
int CategoryId,
int CandidateId);
private sealed record AdminCandidateRow(
int Id,
int CategoryId,
int? StreamerIdentityId,
string DisplayName,
string ChannelSlug,
string Platform,
int NominationTally,
string AcceptanceStatus,
string? AcceptanceNote,
string? ClipCompilationUrl,
string? ClipCompilationTitle,
string? ClipCompilationPlatform,
string ClipEmbedStatus);
private sealed record AdminNominationRow(
int Id,
int? CategoryId,
@@ -314,6 +334,79 @@ public static partial class AdminSeasonManagementEndpoints
string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt);
private static AdminCandidateItemDto[] BuildCandidateItems(
AdminCandidateRow[] candidateRows,
AdminNominationRow[] pendingNominationRows,
AdminNominationRow[] reviewedNominationRows,
AdminVotingEntryRow[] votingEntryRows)
{
var allNominationRows = pendingNominationRows
.Concat(reviewedNominationRows)
.ToArray();
var voteCountByCandidate = votingEntryRows
.GroupBy(item => item.CandidateId)
.ToDictionary(group => group.Key, group => group.Count());
return candidateRows
.Select(item => new AdminCandidateItemDto(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
item.Platform,
ResolveCandidateAvgViewers(item, allNominationRows),
voteCountByCandidate.GetValueOrDefault(item.Id, 0),
item.NominationTally,
item.AcceptanceStatus,
item.AcceptanceNote,
item.ClipCompilationUrl,
item.ClipCompilationTitle,
item.ClipCompilationPlatform,
item.ClipEmbedStatus))
.ToArray();
}
private static int? ResolveCandidateAvgViewers(AdminCandidateRow candidate, AdminNominationRow[] nominationRows)
{
var directMatch = nominationRows
.Where(item => item.CandidateId == candidate.Id && item.AvgViewers.HasValue)
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
if (directMatch.HasValue)
{
return directMatch.Value;
}
if (candidate.StreamerIdentityId.HasValue)
{
var identityMatch = nominationRows
.Where(item => item.StreamerIdentityId == candidate.StreamerIdentityId && item.AvgViewers.HasValue)
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
if (identityMatch.HasValue)
{
return identityMatch.Value;
}
}
var normalizedChannel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalizedChannel))
{
return null;
}
return nominationRows
.Where(item =>
item.AvgViewers.HasValue
&& string.Equals(item.ResolvedChannel?.Trim().TrimStart('@'), normalizedChannel, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
}
private static AdminNominationReviewItemDto ToNominationReviewItem(
AdminNominationRow item,
IEnumerable<dynamic> categoryRows,
@@ -60,6 +60,10 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("UpdateAdminCandidate")
.WithOpenApi();
group.MapGet("/candidates/{candidateId:int}/delete-preview", GetCandidateDeletePreview)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("GetAdminCandidateDeletePreview")
.WithOpenApi();
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("DeleteAdminCandidate")
+139 -37
View File
@@ -14,6 +14,15 @@ public static class AdminSiteSettingsEndpoints
private const string FallbackMaintenanceTitle = "Sternenpause";
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
private const string DefaultHostImageUrl = "/assets/amaterasu2sei_2.png";
private const int MaxHostImageBytes = 8 * 1024 * 1024;
private static readonly IReadOnlyDictionary<string, string> AllowedHostImageContentTypes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/webp"] = ".webp",
};
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
{
@@ -25,6 +34,10 @@ public static class AdminSiteSettingsEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
.WithName("UpdateAdminSiteSettings")
.WithOpenApi();
group.MapPost("/site-settings/host-image", UploadHostImage)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
.WithName("UploadAdminHostImage")
.WithOpenApi();
group.MapGet("/operational-settings", GetOperationalSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("GetAdminOperationalSettings")
@@ -68,43 +81,76 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
return Results.Ok(new AdminSiteSettingsResponse(
settings.HostDisplayName,
settings.HostTagline,
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ImprintContent,
settings.ContactUrl,
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
settings.StreamBannerLiveButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
settings.StreamBannerUseCompletedContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
settings.StreamBannerCompletedButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]"));
return Results.Ok(MapSiteSettingsResponse(settings));
}
private static async Task<IResult> UploadHostImage(
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
if (!context.Request.HasFormContentType)
{
return Results.BadRequest(new { message = "Bitte ein Bild als Formular-Upload senden." });
}
var form = await context.Request.ReadFormAsync(context.RequestAborted);
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0)
{
return Results.BadRequest(new { message = "Bitte ein Hostbild auswählen." });
}
if (file.Length > MaxHostImageBytes)
{
return Results.BadRequest(new { message = "Hostbild ist zu groß. Maximal erlaubt sind 8 MB." });
}
if (!AllowedHostImageContentTypes.TryGetValue(file.ContentType, out var expectedExtension))
{
return Results.BadRequest(new { message = "Bitte PNG, JPG oder WebP hochladen." });
}
var extension = Path.GetExtension(file.FileName);
if (!string.IsNullOrWhiteSpace(extension)
&& !string.Equals(extension, expectedExtension, StringComparison.OrdinalIgnoreCase)
&& !(string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
&& string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)))
{
return Results.BadRequest(new { message = "Dateiendung und Bildtyp passen nicht zusammen." });
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream((int)file.Length);
await stream.CopyToAsync(memory, context.RequestAborted);
settings.HostImageData = memory.ToArray();
settings.HostImageContentType = file.ContentType;
settings.HostImageUpdatedAt = DateTimeOffset.UtcNow;
var session = AdminEndpointConventions.CurrentSession(context);
adminAuditService.AddEntry(
session.TwitchUserId,
"site-settings.host-image.upload",
"site-settings",
settings.Id.ToString(),
"Landingpage-Hostbild wurde aktualisiert.",
new
{
fileName = file.FileName,
fileSize = file.Length,
file.ContentType,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(MapSiteSettingsResponse(settings));
}
private static async Task<IResult> UpdateSiteSettings(
@@ -129,6 +175,7 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.HostArtistName = request.HostArtistName.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
@@ -189,6 +236,61 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(new { saved = true });
}
private static AdminSiteSettingsResponse MapSiteSettingsResponse(SiteSettings settings)
{
return new AdminSiteSettingsResponse(
settings.HostDisplayName,
settings.HostTagline,
settings.HostArtistName,
BuildHostImageUrl(settings),
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ImprintContent,
settings.ContactUrl,
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
settings.StreamBannerLiveButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
settings.StreamBannerUseCompletedContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
settings.StreamBannerCompletedButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]");
}
internal static string BuildHostImageUrl(SiteSettings settings)
{
if (settings.HostImageData is not { Length: > 0 })
{
return DefaultHostImageUrl;
}
var version = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
?? settings.HostImageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
return $"/api/public/host-image?v={version}";
}
private static IResult? NormalizeSiteSettingsUrls(
UpdateSiteSettingsRequest request,
out PublicSiteUrlSettings normalizedUrls,
+4
View File
@@ -14,6 +14,10 @@ public static partial class PublicEndpoints
.WithName("GetSiteStatus")
.WithOpenApi();
group.MapGet("/host-image", GetHostImage)
.WithName("GetPublicHostImage")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
.WithName("GetSeasonCategories")
.WithOpenApi();
@@ -0,0 +1,35 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetHostImage(AwardsDbContext db)
{
var settings = await db.SiteSettings
.AsNoTracking()
.Where(item => item.Id == 1)
.Select(item => new
{
item.HostImageData,
item.HostImageContentType,
item.HostImageUpdatedAt,
})
.FirstOrDefaultAsync();
if (settings?.HostImageData is not { Length: > 0 } imageData
|| string.IsNullOrWhiteSpace(settings.HostImageContentType))
{
return Results.NotFound();
}
var entityTag = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
?? imageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
return Results.File(
imageData,
settings.HostImageContentType,
entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"host-{entityTag}\""),
lastModified: settings.HostImageUpdatedAt);
}
}
@@ -89,8 +89,23 @@ public static partial class PublicEndpoints
.OrderByDescending(item => item.Year)
.ToArrayAsync();
var archivedWinnerYearRows = await db.ArchivedWinners
.AsNoTracking()
.Where(item => latestPublishedWinnerYear == null || item.Year < latestPublishedWinnerYear.Value)
.GroupBy(item => item.Year)
.Select(group => new
{
Year = group.Key,
WinnerCount = group.Count(),
})
.ToArrayAsync();
var archiveYears = archiveYearRows
.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))
.Concat(archivedWinnerYearRows.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount)))
.GroupBy(item => item.Year)
.Select(group => group.Last())
.OrderByDescending(item => item.Year)
.ToArray();
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
@@ -150,6 +165,8 @@ public static partial class PublicEndpoints
new PublicSiteContentDto(
siteSettings.HostDisplayName,
siteSettings.HostTagline,
siteSettings.HostArtistName,
AdminSiteSettingsEndpoints.BuildHostImageUrl(siteSettings),
siteSettings.NewsletterUrl,
siteSettings.ShareXUrl,
siteSettings.ShareDiscordUrl,
@@ -9,6 +9,42 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
{
var latestPublishedWinnerYear = await db.Results
.AsNoTracking()
.Where(result => result.Season.WinnersPublishedAt != null)
.Select(result => (int?)result.Season.Year)
.MaxAsync();
var archivedWinnerRows = await db.ArchivedWinners
.AsNoTracking()
.Where(item => item.Year == year)
.OrderBy(item => item.Category)
.ThenBy(item => item.Subcategory)
.ThenBy(item => item.WinnerName)
.ToArrayAsync();
if (archivedWinnerRows.Length > 0)
{
var archivedItems = archivedWinnerRows
.Select(item =>
{
var metadata = SeasonMappings.InferProfileMetadataFromUrl(item.WinnerUrl, item.WinnerName);
return new WinnerArchiveItemDto(
item.Subcategory,
item.Category,
item.WinnerName,
metadata.Slug,
metadata.Platform,
item.WinnerUrl,
null,
null,
null,
null);
})
.ToArray();
return Results.Ok(new WinnerArchiveResponse(year, archivedItems));
}
var season = await db.Seasons
.AsNoTracking()
.Where(item => item.Year == year)
@@ -16,7 +52,7 @@ public static partial class PublicEndpoints
.FirstOrDefaultAsync();
if (season is null)
{
return Results.NotFound();
return Results.Ok(new WinnerArchiveResponse(year, []));
}
if (season.WinnersPublishedAt is null)
@@ -24,11 +60,6 @@ public static partial class PublicEndpoints
return Results.Ok(new WinnerArchiveResponse(year, []));
}
var latestPublishedWinnerYear = await db.Results
.AsNoTracking()
.Where(result => result.Season.WinnersPublishedAt != null)
.Select(result => (int?)result.Season.Year)
.MaxAsync();
if (latestPublishedWinnerYear == season.Year)
{
return Results.Ok(new WinnerArchiveResponse(year, []));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddArchivedWinners : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ArchivedWinners",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Year = table.Column<int>(type: "integer", nullable: false),
Category = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Subcategory = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
WinnerName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
WinnerUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ArchivedWinners", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ArchivedWinners_Year_Category_Subcategory",
table: "ArchivedWinners",
columns: new[] { "Year", "Category", "Subcategory" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArchivedWinners");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class SeedDemoSponsorsAndShareLinks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE "SiteSettings"
SET
"ShareXUrl" = CASE
WHEN COALESCE(NULLIF("ShareXUrl", ''), '') IN ('', 'https://x.com/intent/tweet')
THEN 'https://x.com/intent/tweet?text=Schaut%20euch%20die%20VTuber%20Star%20Awards%20an!&url=https%3A%2F%2Faward.noveria.net'
ELSE "ShareXUrl"
END,
"ShareDiscordUrl" = CASE
WHEN COALESCE(NULLIF("ShareDiscordUrl", ''), '') IN ('', 'https://discord.gg/')
THEN 'https://discord.gg/jayuhime'
ELSE "ShareDiscordUrl"
END
WHERE "Id" = 1;
INSERT INTO "Sponsors" (
"Id", "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt"
)
SELECT seed."Id", seed."SeasonId", seed."Name", seed."WebsiteUrl", seed."LogoUrl", seed."Description", seed."Tier", seed."SortOrder", seed."IsVisible", seed."CreatedAt", seed."UpdatedAt"
FROM (
VALUES
(9006, 1001, 'Starfall Merch Lab', 'https://example.invalid/starfall-merch', '/demo/sponsors/chibicanvas-market.svg', 'Merch-Pakete, Sticker-Bundles und kleine Giveaways fuer Community-Aktionen rund um die Show.', 'Merch Partner', 60, TRUE, TIMESTAMPTZ '2026-06-01 10:25:00+00', NULL::timestamptz),
(9007, 1001, 'Moonframe Media', 'https://example.invalid/moonframe-media', '/demo/sponsors/prismloop-audio.svg', 'Highlight-Cuts, Social-Assets und kurze Promo-Snippets fuer Voting- und Finale-Phasen.', 'Media Partner', 70, TRUE, TIMESTAMPTZ '2026-06-01 10:30:00+00', NULL::timestamptz),
(9008, 1001, 'PixelHarbor Tools', 'https://example.invalid/pixelharbor-tools', '/demo/sponsors/cloudbeacon-hosting.svg', 'Kleine Creator-Tools fuer Landingpages, Formulare und Community-Orga im Eventbetrieb.', 'Tooling Partner', 80, TRUE, TIMESTAMPTZ '2026-06-01 10:35:00+00', NULL::timestamptz)
) AS seed("Id", "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt")
WHERE EXISTS (SELECT 1 FROM "Seasons" WHERE "Id" = seed."SeasonId")
AND NOT EXISTS (
SELECT 1
FROM "Sponsors" existing
WHERE existing."SeasonId" = seed."SeasonId"
AND existing."Name" = seed."Name"
);
SELECT setval(pg_get_serial_sequence('"Sponsors"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Sponsors"), 1));
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "Sponsors"
WHERE "Id" IN (9006, 9007, 9008)
AND "SeasonId" = 1001;
UPDATE "SiteSettings"
SET
"ShareXUrl" = 'https://x.com/intent/tweet',
"ShareDiscordUrl" = 'https://discord.gg/'
WHERE "Id" = 1
AND "ShareXUrl" = 'https://x.com/intent/tweet?text=Schaut%20euch%20die%20VTuber%20Star%20Awards%20an!&url=https%3A%2F%2Faward.noveria.net'
AND "ShareDiscordUrl" = 'https://discord.gg/jayuhime';
"""
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class SeedExampleSponsorsForCurrentSeason : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
WITH target_season AS (
SELECT "Id"
FROM "Seasons"
WHERE "IsCurrent" = TRUE
ORDER BY "Year" DESC, "Id" DESC
LIMIT 1
)
INSERT INTO "Sponsors" (
"SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt"
)
SELECT
target_season."Id",
seed."Name",
seed."WebsiteUrl",
seed."LogoUrl",
seed."Description",
seed."Tier",
seed."SortOrder",
TRUE,
TIMESTAMPTZ '2026-06-29 19:22:20+00',
NULL::timestamptz
FROM target_season
CROSS JOIN (
VALUES
('HoshiForge Studio', 'https://example.invalid/hoshiforge', '/demo/sponsors/hoshiforge-studio.svg', 'Branding-, Overlay- und Debuet-Visuals fuer VTuber-Projekte und Community-Events.', 'Presenting Sponsor', 10),
('NekoPixel Energy', 'https://example.invalid/nekopixel', '/demo/sponsors/nekopixel-energy.svg', 'Community-fokussierter Drink-Partner fuer lange Showabende, Watchpartys und Creator-Collabs.', 'Gold Partner', 20),
('PrismLoop Audio', 'https://example.invalid/prismloop', '/demo/sponsors/prismloop-audio.svg', 'Audio-Tools, Intro-Packs und Stream-Sounddesign fuer Live-Shows und Highlight-Clips.', 'Gold Partner', 30),
('CloudBeacon Hosting', 'https://example.invalid/cloudbeacon', '/demo/sponsors/cloudbeacon-hosting.svg', 'Skalierbares Hosting fuer Voting, Landingpages und Event-Traffic rund um Showtage.', 'Tech Partner', 40),
('ChibiCanvas Market', 'https://example.invalid/chibicanvas', '/demo/sponsors/chibicanvas-market.svg', 'Merch-, Sticker- und Artist-Marketplace mit Fokus auf VTuber, Emotes und Fanartikel.', 'Community Partner', 50)
) AS seed("Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder")
WHERE NOT EXISTS (
SELECT 1
FROM "Sponsors" existing
WHERE existing."SeasonId" = target_season."Id"
);
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "Sponsors"
WHERE "Name" IN (
'HoshiForge Studio',
'NekoPixel Energy',
'PrismLoop Audio',
'CloudBeacon Hosting',
'ChibiCanvas Market'
);
"""
);
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(Data.AwardsDbContext))]
[Migration("20260629194000_RemoveDemoSeasons")]
public partial class RemoveDemoSeasons : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE "RiskFlags"
SET "SeasonId" = NULL
WHERE "SeasonId" IN (
SELECT "Id"
FROM "Seasons"
WHERE "IsDemo" = TRUE
);
DELETE FROM "Seasons"
WHERE "IsDemo" = TRUE;
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
@@ -0,0 +1,38 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629203000_AddHostImageUpload")]
/// <inheritdoc />
public partial class AddHostImageUpload : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageData" bytea;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageContentType" character varying(80);
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageUpdatedAt" timestamp with time zone;
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageUpdatedAt";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageContentType";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageData";
"""
);
}
}
}
@@ -0,0 +1,34 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629204500_AddHostArtistName")]
/// <inheritdoc />
public partial class AddHostArtistName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostArtistName" character varying(120) NOT NULL DEFAULT '';
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostArtistName";
"""
);
}
}
}
@@ -0,0 +1,38 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629210000_EnsureRuntimeSchemaParity")]
public partial class EnsureRuntimeSchemaParity : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageData" bytea;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageContentType" character varying(80);
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageUpdatedAt" timestamp with time zone;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostArtistName" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "Nominations" ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300);
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "Nominations" DROP COLUMN IF EXISTS "StreamUrl";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostArtistName";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageUpdatedAt";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageContentType";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageData";
"""
);
}
}
@@ -0,0 +1,178 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629213000_EnsureClipSubmissionsTable")]
public partial class EnsureClipSubmissionsTable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"SeasonId" integer NOT NULL,
"CategoryId" integer,
"CandidateId" integer,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"ClipUrl" character varying(500) NOT NULL,
"Title" character varying(200) NOT NULL,
"Creator" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL,
"Status" character varying(20) NOT NULL,
"ReviewNote" character varying(500),
"ReviewedByTwitchId" character varying(120),
"CreatedFromIp" character varying(80) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone,
CONSTRAINT "PK_ClipSubmissions" PRIMARY KEY ("Id")
);
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "SeasonId" integer NOT NULL DEFAULT 0;
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CategoryId" integer;
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CandidateId" integer;
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "SubmittedByTwitchId" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ClipUrl" character varying(500) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Title" character varying(200) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Creator" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Platform" character varying(40) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500);
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120);
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CreatedAt" timestamp with time zone NOT NULL DEFAULT NOW();
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone;
DO $vtsa_clip_constraints$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ClipSubmissions_Seasons_SeasonId'
) THEN
ALTER TABLE "ClipSubmissions"
ADD CONSTRAINT "FK_ClipSubmissions_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons"("Id") ON DELETE CASCADE;
END IF;
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;
$vtsa_clip_constraints$;
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
ON "ClipSubmissions" ("CandidateId");
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
ON "ClipSubmissions" ("SeasonId", "Status");
DO $vtsa_clip_seed$
DECLARE
target_season_id integer;
BEGIN
SELECT "Id"
INTO target_season_id
FROM "Seasons"
WHERE "IsCurrent" = TRUE
ORDER BY "Year" DESC
LIMIT 1;
IF target_season_id IS NULL THEN
RETURN;
END IF;
IF EXISTS (
SELECT 1
FROM "ClipSubmissions"
WHERE "SeasonId" = target_season_id
) THEN
RETURN;
END IF;
INSERT INTO "ClipSubmissions" (
"SeasonId",
"CategoryId",
"CandidateId",
"SubmittedByTwitchId",
"ClipUrl",
"Title",
"Creator",
"Platform",
"Status",
"ReviewNote",
"ReviewedByTwitchId",
"CreatedFromIp",
"CreatedAt",
"ReviewedAt"
)
SELECT
target_season_id,
candidates."CategoryId",
candidates."Id",
seed_rows.submitted_by,
seed_rows.clip_url,
seed_rows.title,
seed_rows.creator,
seed_rows.platform,
seed_rows.status,
seed_rows.review_note,
seed_rows.reviewed_by,
'127.0.0.1',
seed_rows.created_at,
seed_rows.reviewed_at
FROM (
VALUES
('vtsa_demo_current_astra_shining_star_aster', 'viewer_2001', 'https://clips.twitch.tv/demo-nova-finale', 'Astra turns the boss fight', 'viewer_2001', 'Twitch', 'approved', 'Kontext passt.', 'clip_reviewer', TIMESTAMPTZ '2026-06-20 18:12:00+00', TIMESTAMPTZ '2026-06-21 09:00:00+00'),
('vtsa_demo_current_melo_rising_star_aster', 'viewer_2002', 'https://youtu.be/demo-vera-stage', 'Melo sings the finale bridge', 'viewer_2002', 'YouTube', 'approved', NULL, 'clip_reviewer', TIMESTAMPTZ '2026-06-20 20:25:00+00', TIMESTAMPTZ '2026-06-21 09:08:00+00'),
('vtsa_demo_current_lumi_shining_star_aster', 'viewer_2003', 'https://clips.twitch.tv/demo-ember-moment', 'Lumi opens the community event', 'viewer_2003', 'Twitch', 'pending', 'Timing pruefen.', NULL, TIMESTAMPTZ '2026-06-21 11:40:00+00', NULL),
('vtsa_demo_current_velvet_shining_star_aster', 'viewer_2004', 'https://youtu.be/demo-chroma-design', 'Velvet explains the overlay rebuild', 'viewer_2004', 'YouTube', 'approved', NULL, 'clip_reviewer', TIMESTAMPTZ '2026-06-21 15:10:00+00', TIMESTAMPTZ '2026-06-22 08:45:00+00'),
('vtsa_demo_current_lumi_rising_star_aster', 'viewer_2005', 'https://clips.twitch.tv/demo-sora-community', 'Lumi lets chat design the scene', 'viewer_2005', 'Twitch', 'pending', NULL, NULL, TIMESTAMPTZ '2026-06-22 19:55:00+00', NULL)
) AS seed_rows(
channel_slug,
submitted_by,
clip_url,
title,
creator,
platform,
status,
review_note,
reviewed_by,
created_at,
reviewed_at
)
INNER JOIN "Candidates" candidates
ON candidates."SeasonId" = target_season_id
AND lower(candidates."ChannelSlug") = lower(seed_rows.channel_slug);
PERFORM setval(
pg_get_serial_sequence('"ClipSubmissions"', 'Id'),
COALESCE((SELECT MAX("Id") FROM "ClipSubmissions"), 1)
);
END;
$vtsa_clip_seed$;
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "ClipSubmissions"
WHERE "SubmittedByTwitchId" IN ('viewer_2001', 'viewer_2002', 'viewer_2003', 'viewer_2004', 'viewer_2005');
"""
);
}
}
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(Data.AwardsDbContext))]
[Migration("20260629220000_SeedDemoArchivedWinners2025")]
public partial class SeedDemoArchivedWinners2025 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $vtsa_archive_2025$
BEGIN
IF EXISTS (
SELECT 1 FROM "Seasons"
WHERE "Year" = 2025 AND "IsDemo" = FALSE
) THEN
RAISE NOTICE 'Echte 2025-Season vorhanden; Demo-Archivgewinner werden nicht eingespielt.';
RETURN;
END IF;
INSERT INTO "ArchivedWinners" (
"Year", "Category", "Subcategory", "WinnerName", "WinnerUrl", "CreatedAt"
)
VALUES
(2025, 'Gaming', 'Hidden Star', 'Archiv Aster', 'https://twitch.tv/archiv_aster', TIMESTAMPTZ '2025-06-22 10:05:00+00'),
(2025, 'Gaming', 'Rising Star', 'Archiv Beryl', 'https://twitch.tv/archiv_beryl', TIMESTAMPTZ '2025-06-22 10:06:00+00'),
(2025, 'Gaming', 'Shining Star', 'Archiv Coda', 'https://clips.twitch.tv/demo-archiv-coda', TIMESTAMPTZ '2025-06-22 10:07:00+00'),
(2025, 'Music', 'Hidden Star', 'Archiv Drift', 'https://twitch.tv/archiv_drift', TIMESTAMPTZ '2025-06-22 10:08:00+00'),
(2025, 'Music', 'Rising Star', 'Archiv Elara', 'https://youtu.be/demo-archiv-elara', TIMESTAMPTZ '2025-06-22 10:09:00+00'),
(2025, 'Music', 'Shining Star', 'Archiv Finch', 'https://clips.twitch.tv/demo-archiv-finch', TIMESTAMPTZ '2025-06-22 10:10:00+00'),
(2025, 'Community', 'Hidden Star', 'Archiv Lyra', 'https://twitch.tv/archiv_lyra', TIMESTAMPTZ '2025-06-22 10:11:00+00'),
(2025, 'Community', 'Rising Star', 'Archiv Muse', 'https://youtu.be/demo-archiv-muse', TIMESTAMPTZ '2025-06-22 10:12:00+00'),
(2025, 'Community', 'Shining Star', 'Archiv Nia', 'https://clips.twitch.tv/demo-archiv-nia', TIMESTAMPTZ '2025-06-22 10:13:00+00')
ON CONFLICT ("Year", "Category", "Subcategory") DO NOTHING;
PERFORM setval(pg_get_serial_sequence('"ArchivedWinners"', 'Id'), COALESCE((SELECT MAX("Id") FROM "ArchivedWinners"), 1));
END;
$vtsa_archive_2025$;
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "ArchivedWinners"
WHERE "Year" = 2025
AND "Category" IN ('Gaming', 'Music', 'Community')
AND "WinnerName" LIKE 'Archiv %';
"""
);
}
}
@@ -77,6 +77,51 @@ namespace Backend.Migrations
b.ToTable("AdminAuditEntries");
});
modelBuilder.Entity("Backend.Domain.ArchivedWinner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Subcategory")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("WinnerName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("WinnerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Year", "Category", "Subcategory")
.IsUnique();
b.ToTable("ArchivedWinners");
});
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
{
b.Property<int>("Id")
@@ -779,11 +824,26 @@ namespace Backend.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("HostArtistName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("HostDisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("HostImageContentType")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<byte[]>("HostImageData")
.HasColumnType("bytea");
b.Property<DateTimeOffset?>("HostImageUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("HostTagline")
.IsRequired()
.HasMaxLength(160)
+5 -3
View File
@@ -124,7 +124,7 @@ public static class TrackingRulesSettings
new(FlagTrackerUnresolved, "Tracker-Link nicht aufloesbar", true, "high", "Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.", true, true, false, false),
new(FlagUnsupportedPlatform, "Plattform nicht unterstuetzt", true, "medium", "Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.", true, true, false, false),
new(FlagNoTrackerData, "Keine Tracker-Daten", true, "medium", "TwitchTracker hat keinen belastbaren Summary-Wert geliefert.", true, true, false, false),
new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, true, false),
new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, false, false),
new(FlagManualReviewRequired, "Manuelle Pruefung noetig", true, "medium", "Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.", true, true, false, false),
new(FlagLowConfidenceSmallChannel, "Low Confidence Small Channel", false, "low", "Kleine Kanaele koennen manuell tiefer geprueft werden.", false, true, false, false),
new(FlagInsufficientActivityContext, "Zu wenig Aktivitaetskontext", false, "low", "Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.", false, true, false, false),
@@ -206,7 +206,9 @@ public static class TrackingRulesSettings
try
{
return JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [];
return (JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [])
.Select(flag => flag with { BlocksApproval = false })
.ToArray();
}
catch (JsonException)
{
@@ -333,7 +335,7 @@ public static class TrackingRulesSettings
Severity = severity,
AutoTriggerEnabled = stored.AutoTriggerEnabled,
RequiresManualReview = stored.RequiresManualReview,
BlocksApproval = stored.BlocksApproval,
BlocksApproval = false,
AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride,
};
}