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
+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, []));