314 lines
11 KiB
C#
314 lines
11 KiB
C#
using Backend.Common;
|
|
using Backend.Contracts;
|
|
using Backend.Data;
|
|
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 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, review, 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, review, 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("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 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 phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
|
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
|
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
|
if (!isCurrent && !needsWinnerReadiness)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
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 categoriesWithCandidates = await db.Candidates
|
|
.AsNoTracking()
|
|
.Where(item => item.SeasonId == seasonId)
|
|
.Select(item => item.CategoryId)
|
|
.Distinct()
|
|
.CountAsync(cancellationToken);
|
|
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
|
if (emptyCategories > 0)
|
|
{
|
|
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
|
}
|
|
}
|
|
|
|
if (needsWinnerReadiness && categoryIds.Length > 0)
|
|
{
|
|
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.");
|
|
}
|
|
}
|
|
|
|
return issues.ToArray();
|
|
}
|
|
|
|
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." });
|
|
}
|
|
|
|
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." });
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|