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>
584 lines
23 KiB
C#
584 lines
23 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 partial class AdminSeasonManagementEndpoints
|
|
{
|
|
private const int MaxCategoryGroupNameLength = 80;
|
|
private const int MaxCategoryNameLength = 120;
|
|
private const int MaxCategorySlugLength = 120;
|
|
private const int MaxCategoryDescriptionLength = 600;
|
|
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,
|
|
int? StreamerIdentityId,
|
|
string DisplayName,
|
|
string ChannelSlug,
|
|
string AcceptanceStatus);
|
|
|
|
private sealed record CandidateReadinessSnapshot(
|
|
int CategoryId,
|
|
int? StreamerIdentityId,
|
|
string DisplayName,
|
|
string ChannelSlug,
|
|
string AcceptanceStatus,
|
|
string? ClipCompilationUrl);
|
|
|
|
private sealed record WinnerReadinessSnapshot(
|
|
int CategoryId,
|
|
int? StreamerIdentityId,
|
|
string DisplayName,
|
|
string ChannelSlug,
|
|
string? ClipCompilationUrl);
|
|
|
|
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
|
{
|
|
if (request.Year < 2020 || request.Year > 2100)
|
|
{
|
|
return Results.BadRequest(new { message = "Please provide a valid award year." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
|
|
{
|
|
return Results.BadRequest(new { message = "Season name and current phase are required." });
|
|
}
|
|
|
|
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
|
{
|
|
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
|
|
}
|
|
|
|
if (!SeasonMappings.IsSeasonScheduleValid(
|
|
request.NominationStartsAt,
|
|
request.NominationEndsAt,
|
|
request.VotingStartsAt,
|
|
request.VotingEndsAt,
|
|
request.ReviewStartsAt,
|
|
request.ReviewEndsAt,
|
|
request.ShowDate))
|
|
{
|
|
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IResult? ValidateSeasonRequest(UpdateSeasonRequest request)
|
|
{
|
|
if (request.Year < 2020 || request.Year > 2100)
|
|
{
|
|
return Results.BadRequest(new { message = "Please provide a valid award year." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
|
|
{
|
|
return Results.BadRequest(new { message = "Season name and current phase are required." });
|
|
}
|
|
|
|
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
|
{
|
|
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
|
|
}
|
|
|
|
if (!SeasonMappings.IsSeasonScheduleValid(
|
|
request.NominationStartsAt,
|
|
request.NominationEndsAt,
|
|
request.VotingStartsAt,
|
|
request.VotingEndsAt,
|
|
request.ReviewStartsAt,
|
|
request.ReviewEndsAt,
|
|
request.ShowDate))
|
|
{
|
|
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string NormalizeSeasonStreamUrl(string? showStreamUrl)
|
|
{
|
|
return SeasonMappings.NormalizeSeasonStreamUrl(showStreamUrl);
|
|
}
|
|
|
|
private static bool IsKnownSeasonPhase(string? currentPhase)
|
|
{
|
|
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
return value.Contains("show")
|
|
|| value.Contains("abgeschlossen")
|
|
|| value.Contains("archiv")
|
|
|| value.Contains("complete")
|
|
|| value.Contains("ended")
|
|
|| value.Contains("aufbereit")
|
|
|| value.Contains("vorbereit")
|
|
|| value.Contains("pause")
|
|
|| value.Contains("review")
|
|
|| value.Contains("auswert")
|
|
|| value.Contains("vot")
|
|
|| value.Contains("nomin");
|
|
}
|
|
|
|
private static async Task UnsetOtherCurrentSeasonsAsync(
|
|
AwardsDbContext db,
|
|
bool shouldUnsetOthers,
|
|
int? seasonIdToKeep,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!shouldUnsetOthers)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var activeSeasons = await db.Seasons
|
|
.Where(item => item.IsCurrent && (!seasonIdToKeep.HasValue || item.Id != seasonIdToKeep.Value))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
foreach (var activeSeason in activeSeasons)
|
|
{
|
|
activeSeason.IsCurrent = false;
|
|
}
|
|
}
|
|
|
|
private static IResult CreateReadinessError(IEnumerable<string> issues)
|
|
{
|
|
var issueList = issues.ToArray();
|
|
return Results.BadRequest(new
|
|
{
|
|
message = $"Public-/Archiv-Readiness blockiert: {string.Join(" ", issueList)}",
|
|
issues = issueList,
|
|
});
|
|
}
|
|
|
|
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken)
|
|
{
|
|
var season = await db.Seasons
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
|
var settings = await db.SiteSettings
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
|
|
|
return WorkflowRuleSettings.Read(season, 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,
|
|
int? streamerIdentityId,
|
|
string displayName,
|
|
string channelSlug,
|
|
string acceptanceStatus,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var rules = await LoadWorkflowRulesAsync(db, seasonId, 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.StreamerIdentityId,
|
|
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 =>
|
|
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
|
|| 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,
|
|
int copiedCategoryCount)
|
|
{
|
|
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
|
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
|
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
|
if (!isCurrent && !needsWinnerReadiness)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var issues = new List<string>();
|
|
if (copiedCategoryCount <= 0)
|
|
{
|
|
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
|
}
|
|
|
|
if (needsCandidateReadiness)
|
|
{
|
|
issues.Add("Kandidaten muessen vor dieser Phase fuer alle Kategorien gepflegt sein.");
|
|
}
|
|
|
|
if (needsWinnerReadiness)
|
|
{
|
|
issues.Add("Abgeschlossen ist erst moeglich, wenn jede Kategorie einen Gewinner hat.");
|
|
}
|
|
|
|
return issues.ToArray();
|
|
}
|
|
|
|
private static async Task<string[]> BuildSeasonReadinessIssuesAsync(
|
|
AwardsDbContext db,
|
|
int seasonId,
|
|
string currentPhase,
|
|
bool isCurrent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var season = await db.Seasons
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
|
if (season is null)
|
|
{
|
|
return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."];
|
|
}
|
|
|
|
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
|
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
|
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
|
if (!isCurrent && !needsWinnerReadiness)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
|
var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances);
|
|
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
|
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
|
|
|
var categoryIds = await db.Categories
|
|
.AsNoTracking()
|
|
.Where(item => item.SeasonId == seasonId)
|
|
.Select(item => item.Id)
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
var issues = new List<string>();
|
|
if (categoryIds.Length == 0)
|
|
{
|
|
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
|
}
|
|
|
|
if (needsCandidateReadiness && categoryIds.Length > 0)
|
|
{
|
|
var candidateSnapshots = await db.Candidates
|
|
.AsNoTracking()
|
|
.Where(item => item.SeasonId == seasonId)
|
|
.Select(item => new CandidateReadinessSnapshot(
|
|
item.CategoryId,
|
|
item.StreamerIdentityId,
|
|
item.DisplayName,
|
|
item.ChannelSlug,
|
|
item.AcceptanceStatus,
|
|
item.ClipCompilationUrl))
|
|
.ToArrayAsync(cancellationToken);
|
|
var activeCandidates = candidateSnapshots
|
|
.Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
|
.ToArray();
|
|
var categoriesWithCandidates = activeCandidates
|
|
.Select(item => item.CategoryId)
|
|
.Distinct()
|
|
.Count();
|
|
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
|
if (emptyCategories > 0)
|
|
{
|
|
issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten.");
|
|
}
|
|
|
|
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
|
{
|
|
var identityOverflow = activeCandidates
|
|
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
|
.Select(group => new
|
|
{
|
|
Count = group.Count(),
|
|
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
|
})
|
|
.Where(item => item.Count > appearancesRule.Limit)
|
|
.OrderByDescending(item => item.Count)
|
|
.FirstOrDefault();
|
|
if (identityOverflow is not null)
|
|
{
|
|
issues.Add(
|
|
$"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (needsWinnerReadiness && categoryIds.Length > 0)
|
|
{
|
|
if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now))
|
|
{
|
|
issues.Add("Die Award Show liegt noch nicht in der Vergangenheit.");
|
|
}
|
|
|
|
var categoriesWithResults = await db.Results
|
|
.AsNoTracking()
|
|
.Where(item => item.SeasonId == seasonId)
|
|
.Select(item => item.CategoryId)
|
|
.Distinct()
|
|
.CountAsync(cancellationToken);
|
|
var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults);
|
|
if (missingResults > 0)
|
|
{
|
|
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
|
}
|
|
|
|
var resultSnapshots = await db.Results
|
|
.AsNoTracking()
|
|
.Where(item => item.SeasonId == seasonId)
|
|
.Select(item => new WinnerReadinessSnapshot(
|
|
item.CategoryId,
|
|
item.Candidate.StreamerIdentityId,
|
|
item.Candidate.DisplayName,
|
|
item.Candidate.ChannelSlug,
|
|
item.Candidate.ClipCompilationUrl))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
|
{
|
|
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
|
if (missingWinnerClipCount > 0)
|
|
{
|
|
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
|
}
|
|
}
|
|
|
|
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
|
{
|
|
var winnerOverflow = resultSnapshots
|
|
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
|
.Select(group => new
|
|
{
|
|
Count = group.Count(),
|
|
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
|
})
|
|
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
|
.OrderByDescending(item => item.Count)
|
|
.FirstOrDefault();
|
|
if (winnerOverflow is not null)
|
|
{
|
|
issues.Add(
|
|
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
return issues.ToArray();
|
|
}
|
|
|
|
private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug)
|
|
{
|
|
if (streamerIdentityId.HasValue)
|
|
{
|
|
return $"identity:{streamerIdentityId.Value}";
|
|
}
|
|
|
|
return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
|
}
|
|
|
|
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
|
{
|
|
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
|
|| string.Equals(phaseKey, "completed", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool RequiresWinnerReadiness(string phaseKey)
|
|
{
|
|
return string.Equals(phaseKey, "completed", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static IResult? ValidateCategoryRequest(UpsertCategoryRequest request)
|
|
{
|
|
var groupName = request.GroupName.Trim();
|
|
var name = request.Name.Trim();
|
|
var slug = request.Slug.Trim();
|
|
var description = request.Description.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > MaxCategoryGroupNameLength)
|
|
{
|
|
return Results.BadRequest(new { message = $"Category group name is required and must stay below {MaxCategoryGroupNameLength} characters." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(name) || name.Length > MaxCategoryNameLength)
|
|
{
|
|
return Results.BadRequest(new { message = $"Category name is required and must stay below {MaxCategoryNameLength} characters." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(slug) || slug.Length > MaxCategorySlugLength)
|
|
{
|
|
return Results.BadRequest(new { message = $"Category slug is required and must stay below {MaxCategorySlugLength} characters." });
|
|
}
|
|
|
|
if (!slug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_'))
|
|
{
|
|
return Results.BadRequest(new { message = "Category slug contains unsupported characters." });
|
|
}
|
|
|
|
if (description.Length > MaxCategoryDescriptionLength)
|
|
{
|
|
return Results.BadRequest(new { message = $"Category description must stay below {MaxCategoryDescriptionLength} characters." });
|
|
}
|
|
|
|
if (request.SortOrder is < 0 or > 500)
|
|
{
|
|
return Results.BadRequest(new { message = "Category sort order must be between 0 and 500." });
|
|
}
|
|
|
|
if (request.MaxNomineesPerUser is < 1 or > 10)
|
|
{
|
|
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
|
|
}
|
|
|
|
if (request.ViewerRangeMin is < 0 or > 100000)
|
|
{
|
|
return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." });
|
|
}
|
|
|
|
if (request.ViewerRangeMax is < 0 or > 100000)
|
|
{
|
|
return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." });
|
|
}
|
|
|
|
if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin)
|
|
{
|
|
return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." });
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IResult? ValidateCandidateRequest(UpsertCandidateRequest request)
|
|
{
|
|
var displayName = request.DisplayName.Trim();
|
|
var channelSlug = request.ChannelSlug.Trim();
|
|
var platform = request.Platform.Trim();
|
|
|
|
if (request.CategoryId <= 0)
|
|
{
|
|
return Results.BadRequest(new { message = "A valid category is required." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > MaxCandidateDisplayNameLength)
|
|
{
|
|
return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxCandidateDisplayNameLength} characters." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(channelSlug) || channelSlug.Length > MaxCandidateChannelSlugLength)
|
|
{
|
|
return Results.BadRequest(new { message = $"Channel slug is required and must stay below {MaxCandidateChannelSlugLength} characters." });
|
|
}
|
|
|
|
if (!channelSlug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_' or '.'))
|
|
{
|
|
return Results.BadRequest(new { message = "Channel slug contains unsupported characters." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(platform) || platform.Length > MaxCandidatePlatformLength)
|
|
{
|
|
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);
|
|
}
|
|
}
|