Files
vtuber-awards/Backend/Endpoints/AdminExtrasEndpoints.cs
T
AzuTear 18b61bed52 Improve admin candidate modal UX and add clip menu visibility toggle
- Widen AdminCandidateEditorModal to size lg for better readability
- Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link"
- Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport
- Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 18:39:35 +02:00

322 lines
12 KiB
C#

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<string> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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));
}