Files
AzuTear 6b3b0360e7
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s
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>
2026-06-29 21:47:52 +02:00

250 lines
9.4 KiB
C#

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());
}