b53c7fb736
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
253 lines
10 KiB
C#
253 lines
10 KiB
C#
using Backend.Contracts;
|
|
using Backend.Common;
|
|
using Backend.Data;
|
|
using Backend.Domain;
|
|
using Backend.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
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,
|
|
UpsertCandidateRequest request,
|
|
AwardsDbContext db,
|
|
IAdminAuditService adminAuditService)
|
|
{
|
|
var session = AdminEndpointConventions.CurrentSession(context);
|
|
var validationError = ValidateCandidateRequest(request);
|
|
if (validationError is not null)
|
|
{
|
|
return validationError;
|
|
}
|
|
|
|
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId);
|
|
if (category is null)
|
|
{
|
|
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
|
|
}
|
|
|
|
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
|
|
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|
|
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
|
|
{
|
|
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,
|
|
null,
|
|
normalizedDisplayName,
|
|
normalizedChannelSlug,
|
|
normalizedAcceptanceStatus,
|
|
context.RequestAborted);
|
|
if (workflowRuleBlock is not null)
|
|
{
|
|
return workflowRuleBlock;
|
|
}
|
|
|
|
var candidate = new Candidate
|
|
{
|
|
SeasonId = seasonId,
|
|
CategoryId = request.CategoryId,
|
|
DisplayName = normalizedDisplayName,
|
|
ChannelSlug = normalizedChannelSlug,
|
|
Platform = request.Platform.Trim(),
|
|
AcceptanceStatus = normalizedAcceptanceStatus,
|
|
AcceptanceNote = normalizedAcceptanceNote,
|
|
ClipCompilationUrl = normalizedClipUrl,
|
|
ClipCompilationTitle = normalizedClipTitle,
|
|
ClipCompilationPlatform = normalizedClipPlatform,
|
|
ClipEmbedStatus = normalizedClipEmbedStatus,
|
|
};
|
|
|
|
db.Candidates.Add(candidate);
|
|
adminAuditService.AddEntry(
|
|
session.TwitchUserId,
|
|
"candidate.create",
|
|
"candidate",
|
|
request.DisplayName.Trim(),
|
|
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
|
new { seasonId, request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
|
RequestMetadataReader.Read(context));
|
|
|
|
await db.SaveChangesAsync(context.RequestAborted);
|
|
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
|
}
|
|
|
|
private static async Task<IResult> UpdateCandidate(
|
|
HttpContext context,
|
|
int candidateId,
|
|
UpsertCandidateRequest request,
|
|
AwardsDbContext db,
|
|
IAdminAuditService adminAuditService)
|
|
{
|
|
var session = AdminEndpointConventions.CurrentSession(context);
|
|
var validationError = ValidateCandidateRequest(request);
|
|
if (validationError is not null)
|
|
{
|
|
return validationError;
|
|
}
|
|
|
|
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
|
|
if (candidate is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
|
item.Id == request.CategoryId && item.SeasonId == candidate.SeasonId);
|
|
if (targetCategory is null)
|
|
{
|
|
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
|
|
}
|
|
|
|
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
|
|
&& item.Id != candidateId
|
|
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|
|
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
|
|
{
|
|
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,
|
|
candidate.StreamerIdentityId,
|
|
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,
|
|
"candidate.update",
|
|
"candidate",
|
|
candidate.Id.ToString(),
|
|
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
|
new { request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
|
RequestMetadataReader.Read(context));
|
|
|
|
await db.SaveChangesAsync(context.RequestAborted);
|
|
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
|
}
|
|
|
|
private static async Task<IResult> DeleteCandidate(
|
|
HttpContext context,
|
|
int candidateId,
|
|
AwardsDbContext db,
|
|
IAdminAuditService adminAuditService)
|
|
{
|
|
var session = AdminEndpointConventions.CurrentSession(context);
|
|
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
|
|
if (candidate is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
db.Candidates.Remove(candidate);
|
|
adminAuditService.AddEntry(
|
|
session.TwitchUserId,
|
|
"candidate.delete",
|
|
"candidate",
|
|
candidate.Id.ToString(),
|
|
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
|
|
new { candidate.CategoryId, candidate.Platform },
|
|
RequestMetadataReader.Read(context));
|
|
|
|
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);
|
|
}
|
|
}
|