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:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user