Add winner archive, host image upload and live demo data
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1620
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';
|
||||
"""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1620
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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user