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