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