Files
vtuber-awards/Backend/Endpoints/AdminSeasonResultsEndpoints.cs
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
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>
2026-06-28 23:32:21 +02:00

159 lines
5.9 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 async Task<IResult> SetResult(
HttpContext context,
int seasonId,
SetAwardResultRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var category = await db.Categories
.AsNoTracking()
.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 candidate = await db.Candidates
.AsNoTracking()
.FirstOrDefaultAsync(item =>
item.Id == request.CandidateId
&& item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId);
if (candidate is null)
{
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
}
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
{
return CreateWorkflowRuleError(
"Dieser Kandidat hat noch keinen gepflegten Clip-Link. Bitte zuerst die Clip-Compilation am Kandidaten hinterlegen oder die Workflow-Regel umstellen.");
}
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
{
var candidateIdentityKey = WorkflowRuleSettings.CandidateIdentityKey(candidate);
var existingWinnerIdentities = await db.Results
.AsNoTracking()
.Include(item => item.Candidate)
.Where(item => item.SeasonId == seasonId && item.CategoryId != request.CategoryId)
.Select(item => new
{
item.CategoryId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
})
.ToArrayAsync(context.RequestAborted);
var existingWinnerCount = existingWinnerIdentities.Count(item =>
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
if (existingWinnerCount >= winnerPlacementsRule.Limit)
{
return CreateWorkflowRuleError(
$"Diese Person hat bereits {existingWinnerCount} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
}
}
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId);
if (existingResult is null)
{
existingResult = new AwardResult
{
SeasonId = seasonId,
CategoryId = request.CategoryId,
CandidateId = request.CandidateId,
CategoryName = category.Name,
};
db.Results.Add(existingResult);
}
else
{
existingResult.CandidateId = request.CandidateId;
existingResult.CategoryName = category.Name;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"result.set",
"result",
$"{seasonId}:{request.CategoryId}",
$"Gewinner für {category.Name} wurde gesetzt.",
new
{
seasonId,
categoryId = request.CategoryId,
candidateId = request.CandidateId,
candidateName = candidate.DisplayName,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new
{
saved = true,
resultId = existingResult.Id,
seasonId,
categoryId = request.CategoryId,
candidateId = request.CandidateId,
});
}
private static async Task<IResult> DeleteResult(
HttpContext context,
int resultId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var result = await db.Results
.Include(item => item.Category)
.Include(item => item.Candidate)
.FirstOrDefaultAsync(item => item.Id == resultId);
if (result is null)
{
return Results.NotFound();
}
db.Results.Remove(result);
adminAuditService.AddEntry(
session.TwitchUserId,
"result.delete",
"result",
result.Id.ToString(),
$"Gewinner für {result.Category.Name} wurde entfernt.",
new
{
result.SeasonId,
result.CategoryId,
result.CandidateId,
candidateName = result.Candidate.DisplayName,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, resultId });
}
}