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>
This commit is contained in:
@@ -14,6 +14,7 @@ public static class AdminEndpoints
|
||||
group.MapAdminDashboardEndpoints();
|
||||
group.MapAdminSeasonManagementEndpoints();
|
||||
group.MapAdminModerationEndpoints();
|
||||
group.MapAdminExtrasEndpoints();
|
||||
group.MapAdminTeamEndpoints();
|
||||
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
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));
|
||||
}
|
||||
@@ -20,6 +20,18 @@ public static partial class AdminModerationEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("GetAdminNominationLinkBlacklist")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/nominations/link-blacklist", UpdateNominationLinkBlacklist)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("UpdateAdminNominationLinkBlacklist")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/link-blacklist", AddNominationLinkBlacklistEntry)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("AddAdminNominationLinkBlacklistEntry")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/risk-flags", GetRiskFlags)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||
.WithName("GetAdminRiskFlags")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminModerationEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetNominationLinkBlacklist(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateNominationLinkBlacklist(
|
||||
HttpContext context,
|
||||
UpdateNominationLinkBlacklistRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedEntries = NormalizeBlacklistEntries(request.Urls, out var invalidUrl);
|
||||
if (invalidUrl is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Blacklist-Link ist keine gueltige http(s)-URL: {invalidUrl}" });
|
||||
}
|
||||
|
||||
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(normalizedEntries);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination-link-blacklist.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Nominierungs-Link-Blacklist wurde aktualisiert.",
|
||||
new { count = normalizedEntries.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddNominationLinkBlacklistEntry(
|
||||
HttpContext context,
|
||||
AddNominationLinkBlacklistEntryRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(request.Url, out var normalizedUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Blacklist-Link ist keine gueltige http(s)-URL." });
|
||||
}
|
||||
|
||||
var entries = NominationLinkBlacklistSettings.Read(settings).ToList();
|
||||
if (!NominationLinkBlacklistSettings.IsBlocked(normalizedUrl, entries))
|
||||
{
|
||||
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
|
||||
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(entries);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination-link-blacklist.add",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Link wurde zur Nominierungs-Blacklist hinzugefuegt.",
|
||||
new { url = normalizedUrl },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
|
||||
}
|
||||
|
||||
private static AdminNominationLinkBlacklistResponse ToNominationLinkBlacklistResponse(Backend.Domain.SiteSettings settings) =>
|
||||
new(NominationLinkBlacklistSettings.Read(settings)
|
||||
.Select(entry => new AdminNominationLinkBlacklistEntryDto(entry.Url))
|
||||
.ToArray());
|
||||
|
||||
private static NominationLinkBlacklistEntry[] NormalizeBlacklistEntries(string[]? urls, out string? invalidUrl)
|
||||
{
|
||||
invalidUrl = null;
|
||||
var entries = new List<NominationLinkBlacklistEntry>();
|
||||
foreach (var rawUrl in urls ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(rawUrl, out var normalizedUrl))
|
||||
{
|
||||
invalidUrl = rawUrl;
|
||||
return [];
|
||||
}
|
||||
|
||||
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
|
||||
}
|
||||
|
||||
return entries
|
||||
.DistinctBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static readonly string[] CandidateAcceptanceStatuses = ["open", "contacted", "accepted", "declined"];
|
||||
private static readonly string[] CandidateClipEmbedStatuses = ["unchecked", "embeddable", "link_only", "blocked"];
|
||||
|
||||
private static async Task<IResult> CreateCandidate(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
@@ -31,6 +34,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
var normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||
|
||||
if (normalizedClipUrl is null)
|
||||
{
|
||||
normalizedClipTitle = null;
|
||||
normalizedClipPlatform = null;
|
||||
normalizedClipEmbedStatus = "unchecked";
|
||||
}
|
||||
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
@@ -40,6 +57,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
seasonId,
|
||||
request.CategoryId,
|
||||
null,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
var candidate = new Candidate
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
@@ -47,6 +78,12 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
DisplayName = normalizedDisplayName,
|
||||
ChannelSlug = normalizedChannelSlug,
|
||||
Platform = request.Platform.Trim(),
|
||||
AcceptanceStatus = normalizedAcceptanceStatus,
|
||||
AcceptanceNote = normalizedAcceptanceNote,
|
||||
ClipCompilationUrl = normalizedClipUrl,
|
||||
ClipCompilationTitle = normalizedClipTitle,
|
||||
ClipCompilationPlatform = normalizedClipPlatform,
|
||||
ClipEmbedStatus = normalizedClipEmbedStatus,
|
||||
};
|
||||
|
||||
db.Candidates.Add(candidate);
|
||||
@@ -56,7 +93,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
"candidate",
|
||||
request.DisplayName.Trim(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.CategoryId, request.Platform },
|
||||
new { seasonId, request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -92,6 +129,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
var normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||
|
||||
if (normalizedClipUrl is null)
|
||||
{
|
||||
normalizedClipTitle = null;
|
||||
normalizedClipPlatform = null;
|
||||
normalizedClipEmbedStatus = "unchecked";
|
||||
}
|
||||
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == candidate.SeasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
@@ -102,10 +153,30 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
candidate.SeasonId,
|
||||
request.CategoryId,
|
||||
candidateId,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
candidate.CategoryId = request.CategoryId;
|
||||
candidate.DisplayName = normalizedDisplayName;
|
||||
candidate.ChannelSlug = normalizedChannelSlug;
|
||||
candidate.Platform = request.Platform.Trim();
|
||||
candidate.AcceptanceStatus = normalizedAcceptanceStatus;
|
||||
candidate.AcceptanceNote = normalizedAcceptanceNote;
|
||||
candidate.ClipCompilationUrl = normalizedClipUrl;
|
||||
candidate.ClipCompilationTitle = normalizedClipTitle;
|
||||
candidate.ClipCompilationPlatform = normalizedClipPlatform;
|
||||
candidate.ClipEmbedStatus = normalizedClipEmbedStatus;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -113,7 +184,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||
new { request.CategoryId, request.Platform },
|
||||
new { request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -146,4 +217,34 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, candidateId });
|
||||
}
|
||||
|
||||
private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection<string> allowedValues)
|
||||
{
|
||||
var normalized = value?.Trim().ToLowerInvariant();
|
||||
return !string.IsNullOrWhiteSpace(normalized) && allowedValues.Contains(normalized)
|
||||
? normalized
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalCandidateText(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalCandidateUrl(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new BadHttpRequestException("Compilation-Link muss eine gültige http(s)-URL sein.");
|
||||
}
|
||||
|
||||
return uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,13 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.CategoryId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.Platform))
|
||||
item.Platform,
|
||||
item.AcceptanceStatus,
|
||||
item.AcceptanceNote,
|
||||
item.ClipCompilationUrl,
|
||||
item.ClipCompilationTitle,
|
||||
item.ClipCompilationPlatform,
|
||||
item.ClipEmbedStatus))
|
||||
.ToArrayAsync();
|
||||
|
||||
var candidateCounts = candidates
|
||||
|
||||
@@ -56,6 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("DeleteAdminResult")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/workflow-rules", GetWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/workflow-rules", UpdateWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -14,6 +16,17 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
private const int MaxCandidateDisplayNameLength = 120;
|
||||
private const int MaxCandidateChannelSlugLength = 120;
|
||||
private const int MaxCandidatePlatformLength = 60;
|
||||
private const int MaxCandidateAcceptanceNoteLength = 500;
|
||||
private const int MaxCandidateClipUrlLength = 500;
|
||||
private const int MaxCandidateClipTitleLength = 200;
|
||||
private const int MaxCandidateClipPlatformLength = 40;
|
||||
|
||||
private sealed record CandidateRuleSnapshot(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string AcceptanceStatus);
|
||||
|
||||
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||
{
|
||||
@@ -132,6 +145,80 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
return WorkflowRuleSettings.Read(settings);
|
||||
}
|
||||
|
||||
private static IResult CreateWorkflowRuleError(string message) =>
|
||||
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||
|
||||
private static async Task<IResult?> BuildCandidateWorkflowRuleBlockAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
|
||||
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingCandidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
|
||||
&& item.AcceptanceStatus != "declined")
|
||||
.Select(item => new CandidateRuleSnapshot(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||
{
|
||||
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||
if (categoryCount >= finalistsRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
|
||||
}
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string[] BuildNewSeasonReadinessIssues(
|
||||
string currentPhase,
|
||||
bool isCurrent,
|
||||
@@ -311,6 +398,51 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." });
|
||||
}
|
||||
|
||||
if (!IsAllowedCandidateChoice(request.AcceptanceStatus, CandidateAcceptanceStatuses))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Acceptance status must be open, contacted, accepted, or declined." });
|
||||
}
|
||||
|
||||
if (!IsAllowedCandidateChoice(request.ClipEmbedStatus, CandidateClipEmbedStatuses))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Clip embed status must be unchecked, embeddable, link_only, or blocked." });
|
||||
}
|
||||
|
||||
if (request.AcceptanceNote?.Trim().Length > MaxCandidateAcceptanceNoteLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Acceptance note must stay below {MaxCandidateAcceptanceNoteLength} characters." });
|
||||
}
|
||||
|
||||
var clipUrl = request.ClipCompilationUrl?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(clipUrl))
|
||||
{
|
||||
if (clipUrl.Length > MaxCandidateClipUrlLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Compilation link must stay below {MaxCandidateClipUrlLength} characters." });
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Compilation link must be a valid http(s) URL." });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.ClipCompilationTitle?.Trim().Length > MaxCandidateClipTitleLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Compilation title must stay below {MaxCandidateClipTitleLength} characters." });
|
||||
}
|
||||
|
||||
if (request.ClipCompilationPlatform?.Trim().Length > MaxCandidateClipPlatformLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Compilation platform must stay below {MaxCandidateClipPlatformLength} characters." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsAllowedCandidateChoice(string? value, IReadOnlyCollection<string> allowedValues)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) || allowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,40 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
||||
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
"Dieser Kandidat hat noch keinen gepflegten Clip-Link. Bitte zuerst die Clip-Compilation am Kandidaten hinterlegen oder die Workflow-Regel umstellen.");
|
||||
}
|
||||
|
||||
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||
{
|
||||
var candidateIdentityKey = WorkflowRuleSettings.CandidateIdentityKey(candidate);
|
||||
var existingWinnerIdentities = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Candidate)
|
||||
.Where(item => item.SeasonId == seasonId && item.CategoryId != request.CategoryId)
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
var existingWinnerCount = existingWinnerIdentities.Count(item =>
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
|
||||
|
||||
if (existingWinnerCount >= winnerPlacementsRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
$"Diese Person hat bereits {existingWinnerCount} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId);
|
||||
|
||||
@@ -13,6 +13,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
{
|
||||
private const string FallbackMaintenanceTitle = "Sternenpause";
|
||||
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||
|
||||
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
@@ -32,6 +33,14 @@ public static class AdminSiteSettingsEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOperationalSettings")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/optional-feature-settings", GetOptionalFeatureSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminOptionalFeatureSettings")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/optional-feature-settings", UpdateOptionalFeatureSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOptionalFeatureSettings")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -57,6 +66,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ContactContent,
|
||||
settings.SponsorsUrl,
|
||||
settings.SponsorsContent,
|
||||
settings.ShowactsUrl,
|
||||
settings.ShowactsContent,
|
||||
SeasonMappings.ReadSocialLinks(settings),
|
||||
SeasonMappings.ReadFaqItems(settings)));
|
||||
}
|
||||
@@ -100,6 +111,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ContactContent = request.ContactContent.Trim();
|
||||
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
||||
settings.SponsorsContent = request.SponsorsContent.Trim();
|
||||
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
|
||||
settings.ShowactsContent = request.ShowactsContent.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
|
||||
@@ -133,7 +146,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
@@ -144,6 +158,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
ImprintUrl = imprintUrl,
|
||||
ContactUrl = contactUrl,
|
||||
SponsorsUrl = sponsorsUrl,
|
||||
ShowactsUrl = showactsUrl,
|
||||
};
|
||||
|
||||
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
||||
@@ -187,6 +202,108 @@ public static class AdminSiteSettingsEndpoints
|
||||
public string ImprintUrl { get; set; } = string.Empty;
|
||||
public string ContactUrl { get; set; } = string.Empty;
|
||||
public string SponsorsUrl { get; set; } = string.Empty;
|
||||
public string ShowactsUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOptionalFeatureSettings(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
||||
HttpContext context,
|
||||
UpdateOptionalFeatureSettingsRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToOptionalFeatureSettingsResponse(settings);
|
||||
var disabledMessage = NormalizeOptionalFeatureText(
|
||||
request.ClipSubmissionDisabledMessage,
|
||||
FallbackClipSubmissionDisabledMessage,
|
||||
240);
|
||||
var showactDisabledMessage = NormalizeOptionalFeatureText(
|
||||
request.ShowactApplicationDisabledMessage,
|
||||
"Showact-Bewerbungen sind aktuell geschlossen.",
|
||||
240);
|
||||
|
||||
settings.ClipSubmissionsEnabled = request.ClipSubmissionsEnabled;
|
||||
settings.ClipReviewEnabled = request.ClipReviewEnabled;
|
||||
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
||||
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
||||
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
||||
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
||||
settings.SponsorsVisible = request.SponsorsVisible;
|
||||
|
||||
var after = ToOptionalFeatureSettingsResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"optional-features.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Optionale Workflow-Features wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
before,
|
||||
after,
|
||||
changes = BuildOptionalFeatureChanges(before, after),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static AdminOptionalFeatureSettingsResponse ToOptionalFeatureSettingsResponse(SiteSettings settings) =>
|
||||
new(
|
||||
settings.ClipSubmissionsEnabled,
|
||||
settings.ClipReviewEnabled,
|
||||
settings.ClipAdminMenuVisible,
|
||||
string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage)
|
||||
? FallbackClipSubmissionDisabledMessage
|
||||
: settings.ClipSubmissionDisabledMessage,
|
||||
settings.ShowactApplicationsEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: settings.ShowactApplicationDisabledMessage,
|
||||
settings.SponsorsVisible);
|
||||
|
||||
private static string NormalizeOptionalFeatureText(string? value, string fallback, int maxLength)
|
||||
{
|
||||
var trimmed = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static object[] BuildOptionalFeatureChanges(
|
||||
AdminOptionalFeatureSettingsResponse before,
|
||||
AdminOptionalFeatureSettingsResponse after)
|
||||
{
|
||||
var changes = new List<object>();
|
||||
AddOperationalChange(changes, "clipSubmissionsEnabled", "Clip-Einreichung", before.ClipSubmissionsEnabled, after.ClipSubmissionsEnabled);
|
||||
AddOperationalChange(changes, "clipReviewEnabled", "Clip-Review", before.ClipReviewEnabled, after.ClipReviewEnabled);
|
||||
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
||||
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
||||
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
||||
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
||||
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
||||
return changes.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWorkflowRules(
|
||||
HttpContext context,
|
||||
UpdateWorkflowRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = WorkflowRuleSettings.Read(settings);
|
||||
var mergedRules = WorkflowRuleSettings.Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
|
||||
return requestRule is null
|
||||
? defaultRule
|
||||
: new WorkflowRuleSetting(
|
||||
defaultRule.Key,
|
||||
defaultRule.Label,
|
||||
requestRule.Enabled,
|
||||
requestRule.Limit,
|
||||
requestRule.Mode,
|
||||
defaultRule.Description);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(settings);
|
||||
var changes = after
|
||||
.Select(rule =>
|
||||
{
|
||||
var previous = before.First(item => item.Key == rule.Key);
|
||||
return new
|
||||
{
|
||||
field = rule.Key,
|
||||
label = rule.Label,
|
||||
from = $"{previous.Enabled}/{previous.Limit}/{previous.Mode}",
|
||||
to = $"{rule.Enabled}/{rule.Limit}/{rule.Mode}",
|
||||
sensitive = false,
|
||||
};
|
||||
})
|
||||
.Where(change => change.from != change.to)
|
||||
.ToArray();
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"workflow-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Workflow-Regeln wurden aktualisiert.",
|
||||
new { changes },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(after.Select(ToWorkflowRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static AdminWorkflowRuleDto ToWorkflowRuleDto(WorkflowRuleSetting rule) =>
|
||||
new(rule.Key, rule.Label, rule.Enabled, rule.Limit, rule.Mode, rule.Description);
|
||||
}
|
||||
@@ -28,6 +28,22 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
||||
}
|
||||
|
||||
var siteSettings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (siteSettings is null)
|
||||
{
|
||||
return Results.Problem("Site settings are missing.");
|
||||
}
|
||||
|
||||
if (!siteSettings.ClipSubmissionsEnabled)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||
: siteSettings.ClipSubmissionDisabledMessage;
|
||||
return Results.BadRequest(new { message });
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||
if (clipSeasonResolution.Result is not null)
|
||||
|
||||
@@ -22,6 +22,10 @@ public static partial class PublicEndpoints
|
||||
.WithName("GetWinnerArchive")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{year:int}/sponsors", GetSponsors)
|
||||
.WithName("GetPublicSponsors")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
||||
.WithName("GetUserParticipation")
|
||||
.WithOpenApi();
|
||||
@@ -41,6 +45,11 @@ public static partial class PublicEndpoints
|
||||
.WithName("CreateClip")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/showacts", CreateShowactApplication)
|
||||
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||
.WithName("CreateShowactApplication")
|
||||
.WithOpenApi();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Year == year);
|
||||
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null || !settings.SponsorsVisible)
|
||||
{
|
||||
return Results.Ok(new PublicSponsorsResponse(year, []));
|
||||
}
|
||||
|
||||
var sponsors = await db.Sponsors
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == season.Id && item.IsVisible)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.Select(item => new SponsorDto(
|
||||
item.Id,
|
||||
item.SeasonId,
|
||||
item.Name,
|
||||
item.WebsiteUrl,
|
||||
item.LogoUrl,
|
||||
item.Description,
|
||||
item.Tier,
|
||||
item.SortOrder,
|
||||
item.IsVisible))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateShowactApplication(
|
||||
HttpContext context,
|
||||
CreateShowactApplicationRequest request,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (settings is null || !settings.ShowactApplicationsEnabled)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: settings.ShowactApplicationDisabledMessage,
|
||||
});
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||
}
|
||||
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
|
||||
private static string NormalizePublicText(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));
|
||||
}
|
||||
@@ -54,6 +54,17 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var linkBlacklist = NominationLinkBlacklistSettings.Read(settings);
|
||||
var blacklistedStreamUrl = submittedNominations
|
||||
.Select(item => item.StreamUrl)
|
||||
.FirstOrDefault(item => NominationLinkBlacklistSettings.IsBlocked(item, linkBlacklist));
|
||||
|
||||
if (blacklistedStreamUrl is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
||||
}
|
||||
|
||||
var category = await db.Categories
|
||||
.Include(item => item.Season)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
||||
|
||||
@@ -42,6 +42,10 @@ public static partial class PublicEndpoints
|
||||
WinnerName = result.Candidate.DisplayName,
|
||||
WinnerSlug = result.Candidate.ChannelSlug,
|
||||
WinnerPlatform = result.Candidate.Platform,
|
||||
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
@@ -52,7 +56,11 @@ public static partial class PublicEndpoints
|
||||
result.WinnerName,
|
||||
result.WinnerSlug,
|
||||
result.WinnerPlatform,
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||
result.ClipUrl,
|
||||
result.ClipTitle,
|
||||
result.ClipPlatform,
|
||||
result.ClipEmbedStatus))
|
||||
.ToArray();
|
||||
|
||||
var archiveYearRows = await db.Results
|
||||
@@ -110,6 +118,17 @@ public static partial class PublicEndpoints
|
||||
siteSettings.PrivacyPolicyContent,
|
||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||
SeasonMappings.BuildFooterLinks(siteSettings)),
|
||||
new PublicFeatureFlagsDto(
|
||||
siteSettings.ClipSubmissionsEnabled,
|
||||
siteSettings.ClipReviewEnabled,
|
||||
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||
: siteSettings.ClipSubmissionDisabledMessage,
|
||||
siteSettings.ShowactApplicationsEnabled,
|
||||
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: siteSettings.ShowactApplicationDisabledMessage,
|
||||
siteSettings.SponsorsVisible),
|
||||
SeasonMappings.ReadFaqItems(siteSettings));
|
||||
|
||||
return Results.Ok(response);
|
||||
|
||||
@@ -54,17 +54,36 @@ public static partial class PublicEndpoints
|
||||
category.GroupName,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser,
|
||||
category.Candidates.Select(candidate =>
|
||||
category.Candidates
|
||||
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(candidate =>
|
||||
{
|
||||
var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey);
|
||||
var candidateClipUrl = string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)
|
||||
|| string.Equals(candidate.ClipEmbedStatus, "blocked", StringComparison.OrdinalIgnoreCase)
|
||||
? null
|
||||
: candidate.ClipCompilationUrl.Trim();
|
||||
var clipUrl = candidateClipUrl ?? clip?.ClipUrl;
|
||||
var clipTitle = candidateClipUrl is not null
|
||||
? string.IsNullOrWhiteSpace(candidate.ClipCompilationTitle) ? "Highlight-Clip ansehen" : candidate.ClipCompilationTitle.Trim()
|
||||
: clip?.Title;
|
||||
var clipPlatform = candidateClipUrl is not null
|
||||
? string.IsNullOrWhiteSpace(candidate.ClipCompilationPlatform) ? candidate.Platform : candidate.ClipCompilationPlatform.Trim()
|
||||
: clip?.Platform;
|
||||
var clipEmbedStatus = candidateClipUrl is not null
|
||||
? candidate.ClipEmbedStatus
|
||||
: null;
|
||||
|
||||
return new CandidateSummaryDto(
|
||||
candidate.Id,
|
||||
candidate.DisplayName,
|
||||
candidate.ChannelSlug,
|
||||
SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug),
|
||||
candidate.Platform,
|
||||
clip?.ClipUrl,
|
||||
clip?.Title,
|
||||
clip?.Platform);
|
||||
clipUrl,
|
||||
clipTitle,
|
||||
clipPlatform,
|
||||
clipEmbedStatus);
|
||||
}).ToArray()))
|
||||
.ToArray()));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ public static partial class PublicEndpoints
|
||||
WinnerName = result.Candidate.DisplayName,
|
||||
WinnerSlug = result.Candidate.ChannelSlug,
|
||||
WinnerPlatform = result.Candidate.Platform,
|
||||
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
@@ -44,7 +48,11 @@ public static partial class PublicEndpoints
|
||||
result.WinnerName,
|
||||
result.WinnerSlug,
|
||||
result.WinnerPlatform,
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||
result.ClipUrl,
|
||||
result.ClipTitle,
|
||||
result.ClipPlatform,
|
||||
result.ClipEmbedStatus))
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new WinnerArchiveResponse(year, items));
|
||||
|
||||
Reference in New Issue
Block a user