using Backend.Common; using Backend.Contracts; using Backend.Data; using Backend.Domain; using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; public static class AdminExtrasEndpoints { private static readonly string[] ContentPermissions = ["content", "settings"]; private static readonly HashSet AllowedShowactStatuses = new(StringComparer.OrdinalIgnoreCase) { "pending", "shortlisted", "accepted", "rejected", }; public static RouteGroupBuilder MapAdminExtrasEndpoints(this RouteGroupBuilder group) { group.MapGet("/seasons/{seasonId:int}/showacts", GetShowactApplications) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions)) .WithName("GetAdminShowactApplications") .WithOpenApi(); group.MapPost("/showacts/{applicationId:int}/status", UpdateShowactStatus) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) .WithName("UpdateAdminShowactStatus") .WithOpenApi(); group.MapDelete("/showacts/{applicationId:int}", DeleteShowactApplication) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) .WithName("DeleteAdminShowactApplication") .WithOpenApi(); group.MapGet("/seasons/{seasonId:int}/sponsors", GetSponsors) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions)) .WithName("GetAdminSponsors") .WithOpenApi(); group.MapPost("/seasons/{seasonId:int}/sponsors", CreateSponsor) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) .WithName("CreateAdminSponsor") .WithOpenApi(); group.MapPut("/sponsors/{sponsorId:int}", UpdateSponsor) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) .WithName("UpdateAdminSponsor") .WithOpenApi(); group.MapDelete("/sponsors/{sponsorId:int}", DeleteSponsor) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) .WithName("DeleteAdminSponsor") .WithOpenApi(); return group; } private static async Task GetShowactApplications(int seasonId, AwardsDbContext db) { var applications = await db.ShowactApplications .AsNoTracking() .Where(item => item.SeasonId == seasonId) .OrderBy(item => item.Status == "pending" ? 0 : 1) .ThenByDescending(item => item.CreatedAt) .Select(item => ToDto(item)) .ToArrayAsync(); return Results.Ok(applications); } private static async Task UpdateShowactStatus( HttpContext context, int applicationId, UpdateShowactStatusRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId); if (application is null) { return Results.NotFound(); } var status = NormalizeStatus(request.Status); if (!AllowedShowactStatuses.Contains(status)) { return Results.BadRequest(new { message = "Status muss pending, shortlisted, accepted oder rejected sein." }); } var session = AdminEndpointConventions.CurrentSession(context); var before = new { application.Status, application.ReviewNote }; application.Status = status; application.ReviewNote = NormalizeText(request.ReviewNote, 500); application.ReviewedByTwitchId = session.TwitchUserId; application.ReviewedAt = DateTimeOffset.UtcNow; adminAuditService.AddEntry( session.TwitchUserId, "showact.status", "showact-application", application.Id.ToString(), $"Showact-Bewerbung von {application.ArtistName} wurde auf {status} gesetzt.", new { before, after = new { application.Status, application.ReviewNote } }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, application = ToDto(application) }); } private static async Task DeleteShowactApplication( HttpContext context, int applicationId, AwardsDbContext db, IAdminAuditService adminAuditService) { var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId); if (application is null) { return Results.NotFound(); } var session = AdminEndpointConventions.CurrentSession(context); db.ShowactApplications.Remove(application); adminAuditService.AddEntry( session.TwitchUserId, "showact.delete", "showact-application", application.Id.ToString(), $"Showact-Bewerbung von {application.ArtistName} wurde geloescht.", new { application.ArtistName, application.ContactEmail, application.Status }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { deleted = true, applicationId }); } private static async Task GetSponsors(int seasonId, AwardsDbContext db) { var sponsors = await db.Sponsors .AsNoTracking() .Where(item => item.SeasonId == seasonId) .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .Select(item => ToDto(item)) .ToArrayAsync(); return Results.Ok(sponsors); } private static async Task CreateSponsor( HttpContext context, int seasonId, UpsertSponsorRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { if (!await db.Seasons.AnyAsync(item => item.Id == seasonId, context.RequestAborted)) { return Results.NotFound(); } var sponsor = new Sponsor { SeasonId = seasonId, CreatedAt = DateTimeOffset.UtcNow }; var validation = ApplySponsorRequest(sponsor, request); if (validation is not null) { return validation; } db.Sponsors.Add(sponsor); var session = AdminEndpointConventions.CurrentSession(context); adminAuditService.AddEntry( session.TwitchUserId, "sponsor.create", "sponsor", "new", $"Sponsor {sponsor.Name} wurde angelegt.", ToDto(sponsor), RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) }); } private static async Task UpdateSponsor( HttpContext context, int sponsorId, UpsertSponsorRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted); if (sponsor is null) { return Results.NotFound(); } var before = ToDto(sponsor); var validation = ApplySponsorRequest(sponsor, request); if (validation is not null) { return validation; } sponsor.UpdatedAt = DateTimeOffset.UtcNow; var session = AdminEndpointConventions.CurrentSession(context); adminAuditService.AddEntry( session.TwitchUserId, "sponsor.update", "sponsor", sponsor.Id.ToString(), $"Sponsor {sponsor.Name} wurde aktualisiert.", new { before, after = ToDto(sponsor) }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) }); } private static async Task DeleteSponsor( HttpContext context, int sponsorId, AwardsDbContext db, IAdminAuditService adminAuditService) { var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted); if (sponsor is null) { return Results.NotFound(); } var session = AdminEndpointConventions.CurrentSession(context); db.Sponsors.Remove(sponsor); adminAuditService.AddEntry( session.TwitchUserId, "sponsor.delete", "sponsor", sponsor.Id.ToString(), $"Sponsor {sponsor.Name} wurde geloescht.", ToDto(sponsor), RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { deleted = true, sponsorId }); } private static IResult? ApplySponsorRequest(Sponsor sponsor, UpsertSponsorRequest request) { var name = NormalizeText(request.Name, 120); if (string.IsNullOrWhiteSpace(name)) { return Results.BadRequest(new { message = "Sponsor-Name ist erforderlich." }); } var websiteUrl = NormalizeText(request.WebsiteUrl, 500); var logoUrl = NormalizeText(request.LogoUrl, 500); if (!IsBlankOrHttpUrl(websiteUrl) || !IsBlankOrHttpUrl(logoUrl)) { return Results.BadRequest(new { message = "Sponsor-Links muessen gueltige http(s)-URLs sein." }); } sponsor.Name = name; sponsor.WebsiteUrl = websiteUrl; sponsor.LogoUrl = logoUrl; sponsor.Description = NormalizeText(request.Description, 500); sponsor.Tier = NormalizeText(request.Tier, 80); if (string.IsNullOrWhiteSpace(sponsor.Tier)) { sponsor.Tier = "Partner"; } sponsor.SortOrder = Math.Clamp(request.SortOrder, 0, 9999); sponsor.IsVisible = request.IsVisible; return null; } private static SponsorDto ToDto(Sponsor sponsor) => new( sponsor.Id, sponsor.SeasonId, sponsor.Name, sponsor.WebsiteUrl, sponsor.LogoUrl, sponsor.Description, sponsor.Tier, sponsor.SortOrder, sponsor.IsVisible); private static ShowactApplicationDto ToDto(ShowactApplication application) => new( application.Id, application.SeasonId, application.ArtistName, application.ContactEmail, application.ContactDiscord, application.PlatformUrl, application.PerformanceType, application.Description, application.TechnicalNotes, application.ReferenceUrl, application.Status, application.ReviewNote, application.CreatedAt, application.ReviewedAt); private static string NormalizeStatus(string? value) => string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant(); private static string NormalizeText(string? value, int maxLength) { var trimmed = (value ?? string.Empty).Trim(); return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; } private static bool IsBlankOrHttpUrl(string value) => string.IsNullOrWhiteSpace(value) || (Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)); }