18b61bed52
- 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>
322 lines
12 KiB
C#
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));
|
|
}
|