6b3b0360e7
Deliver the demo-ready feature set and seed data so the live site can be presented end to end: - Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager and public archive surface, backed by AddArchivedWinners migration. - Host presentation: host image upload and artist name on SiteSettings with public image endpoint and supporting migrations. - Clip submissions: idempotent table-ensure migration plus current-season demo clips for review workflows. - Demo seed data: sponsors, share links and 2025 archived winners, with a guarded RemoveDemoSeasons cleanup; all seeds guard against real data. - EnsureRuntimeSchemaParity migration to align runtime schema defensively. - Admin/home UI refinements; remove unused team role permissions modal and dead share-quick-links code. All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT) and skip when real season data is present. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
13 KiB
C#
331 lines
13 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> GetCandidateDeletePreview(
|
|
int candidateId,
|
|
AwardsDbContext db,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var candidate = await db.Candidates
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == candidateId, cancellationToken);
|
|
if (candidate is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var nominationCount = await db.Nominations.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
|
var clipCount = await db.ClipSubmissions.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
|
var voteCount = await db.VoteEntries.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
|
var resultCount = await db.Results.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
candidateId,
|
|
nominationCount,
|
|
clipCount,
|
|
voteCount,
|
|
resultCount,
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
var linkedNominations = await db.Nominations
|
|
.Where(item => item.CandidateId == candidateId)
|
|
.ToListAsync(context.RequestAborted);
|
|
if (linkedNominations.Count > 0)
|
|
{
|
|
db.Nominations.RemoveRange(linkedNominations);
|
|
}
|
|
|
|
var linkedClips = await db.ClipSubmissions
|
|
.Where(item => item.CandidateId == candidateId)
|
|
.ToListAsync(context.RequestAborted);
|
|
if (linkedClips.Count > 0)
|
|
{
|
|
db.ClipSubmissions.RemoveRange(linkedClips);
|
|
}
|
|
|
|
var linkedVoteEntries = await db.VoteEntries
|
|
.Where(item => item.CandidateId == candidateId)
|
|
.ToListAsync(context.RequestAborted);
|
|
if (linkedVoteEntries.Count > 0)
|
|
{
|
|
db.VoteEntries.RemoveRange(linkedVoteEntries);
|
|
}
|
|
|
|
var linkedResults = await db.Results
|
|
.Where(item => item.CandidateId == candidateId)
|
|
.ToListAsync(context.RequestAborted);
|
|
if (linkedResults.Count > 0)
|
|
{
|
|
db.Results.RemoveRange(linkedResults);
|
|
}
|
|
|
|
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,
|
|
deletedNominations = linkedNominations.Count,
|
|
deletedClips = linkedClips.Count,
|
|
deletedVoteEntries = linkedVoteEntries.Count,
|
|
deletedResults = linkedResults.Count,
|
|
},
|
|
RequestMetadataReader.Read(context));
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(context.RequestAborted);
|
|
return Results.Ok(new { deleted = true, candidateId });
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return Results.BadRequest(new
|
|
{
|
|
message = "Kandidat konnte nicht gelöscht werden, weil noch verknüpfte Daten blockieren.",
|
|
});
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|