Files
vtuber-awards/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs
T
AzuTear 6b3b0360e7
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s
Add winner archive, host image upload and live demo data
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>
2026-06-29 21:47:52 +02:00

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