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>
This commit is contained in:
@@ -303,7 +303,8 @@ public static class AdminExtrasEndpoints
|
||||
application.Status,
|
||||
application.ReviewNote,
|
||||
application.CreatedAt,
|
||||
application.ReviewedAt);
|
||||
application.ReviewedAt,
|
||||
application.FieldResponsesJson ?? "{}");
|
||||
|
||||
private static string NormalizeStatus(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
||||
|
||||
@@ -20,6 +20,14 @@ public static partial class AdminModerationEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("ReopenRejectedAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("UpdateNominationTrackingReview")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("GetAdminNominationLinkBlacklist")
|
||||
|
||||
@@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations
|
||||
.Include(item => item.Category)
|
||||
.Include(item => item.SuggestedCategory)
|
||||
.Include(item => item.StreamerIdentity)
|
||||
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||
|
||||
if (nomination is null)
|
||||
@@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Any(flag => flag.BlocksApproval)
|
||||
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
|
||||
}
|
||||
|
||||
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
||||
}
|
||||
|
||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
||||
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
|
||||
if (!categoryId.HasValue)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
|
||||
}
|
||||
|
||||
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||
item.Id == categoryId.Value
|
||||
&& item.SeasonId == nomination.SeasonId
|
||||
&& item.GroupName == nomination.CategoryGroupName);
|
||||
if (targetCategory is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
|
||||
}
|
||||
|
||||
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform)
|
||||
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
|
||||
: request.Platform.Trim();
|
||||
var normalizedDisplayName = rawDisplayName.ToLower();
|
||||
var normalizedChannelSlug = channelSlug.ToLower();
|
||||
var normalizedPlatform = platform.ToLower();
|
||||
|
||||
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryId == nomination.CategoryId
|
||||
&& item.CategoryId == targetCategory.Id
|
||||
&& (
|
||||
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
||
|
||||
item.DisplayName.ToLower() == normalizedDisplayName
|
||||
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||
&& item.Platform.ToLower() == normalizedPlatform)
|
||||
));
|
||||
|
||||
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
nomination.SeasonId,
|
||||
targetCategory.Id,
|
||||
existingCandidate?.Id,
|
||||
nomination.StreamerIdentityId,
|
||||
rawDisplayName,
|
||||
channelSlug,
|
||||
existingCandidate?.AcceptanceStatus ?? "open",
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
var candidate = existingCandidate;
|
||||
if (candidate is null)
|
||||
{
|
||||
candidate = new Candidate
|
||||
{
|
||||
SeasonId = nomination.SeasonId,
|
||||
CategoryId = nomination.CategoryId,
|
||||
CategoryId = targetCategory.Id,
|
||||
StreamerIdentityId = nomination.StreamerIdentityId,
|
||||
DisplayName = rawDisplayName,
|
||||
ChannelSlug = channelSlug,
|
||||
Platform = platform,
|
||||
@@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
|
||||
candidate.DisplayName = rawDisplayName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||
@@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
nomination.CandidateId = candidate.Id;
|
||||
nomination.Status = "approved";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var uniqueViewerCount = relatedNominations
|
||||
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
|
||||
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = candidate.Id;
|
||||
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
|
||||
relatedNomination.Status = "approved";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.approve",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
|
||||
new
|
||||
{
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
targetCategoryId = targetCategory.Id,
|
||||
targetCategoryName = targetCategory.Name,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
uniqueViewerCount,
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
nominationId = nomination.Id,
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
uniqueViewerCount,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectNomination(
|
||||
@@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
nomination.CandidateId = null;
|
||||
nomination.Status = "rejected";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "rejected";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.",
|
||||
new { nomination.ReviewNote },
|
||||
new
|
||||
{
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReopenRejectedNomination(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
ReopenRejectedNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "pending";
|
||||
relatedNomination.ReviewNote = null;
|
||||
relatedNomination.ReviewedAt = null;
|
||||
relatedNomination.ReviewedByTwitchId = null;
|
||||
}
|
||||
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.reopen",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.",
|
||||
new
|
||||
{
|
||||
reviewNote,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateNominationTrackingReview(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
UpdateNominationTrackingReviewRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedStatus = request.Status?.Trim().ToLowerInvariant();
|
||||
if (normalizedStatus is not ("reviewed" or "overridden"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
var requiresOverrideNote = relatedNominations
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.Any(flag => flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." });
|
||||
}
|
||||
|
||||
foreach (var item in relatedNominations)
|
||||
{
|
||||
item.TrackingReviewStatus = normalizedStatus;
|
||||
item.TrackingReviewNote = reviewNote;
|
||||
item.TrackingReviewedByTwitchId = session.TwitchUserId;
|
||||
item.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.tracking-review.update",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.",
|
||||
new
|
||||
{
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
status = normalizedStatus,
|
||||
reviewNote,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId, status = normalizedStatus });
|
||||
}
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedPendingNominationsAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
CancellationToken cancellationToken)
|
||||
=> await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken);
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedNominationsByStatusAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
string status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryGroupName == nomination.CategoryGroupName
|
||||
&& item.Status == status);
|
||||
|
||||
if (nomination.StreamerIdentityId.HasValue)
|
||||
{
|
||||
return await query
|
||||
.Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedStreamUrl))
|
||||
{
|
||||
var rows = await query.ToArrayAsync(cancellationToken);
|
||||
return rows
|
||||
.Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [nomination];
|
||||
}
|
||||
|
||||
private static string NormalizeModerationStreamUrl(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values) =>
|
||||
values
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||
?? string.Empty;
|
||||
|
||||
private static async Task<IResult?> BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
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 season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
var rules = WorkflowRuleSettings.Read(season, settings);
|
||||
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
|
||||
{
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||
{
|
||||
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||
if (categoryCount >= finalistsRule.Limit)
|
||||
{
|
||||
return CreateModerationWorkflowRuleError(
|
||||
$"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 CreateModerationWorkflowRuleError(
|
||||
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult CreateModerationWorkflowRuleError(string message) =>
|
||||
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||
|
||||
private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Length == 0)
|
||||
{
|
||||
nomination.TrackingReviewStatus = "clear";
|
||||
nomination.TrackingReviewNote = null;
|
||||
nomination.TrackingReviewedByTwitchId = null;
|
||||
nomination.TrackingReviewedAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim();
|
||||
nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note))
|
||||
? "overridden"
|
||||
: "reviewed";
|
||||
nomination.TrackingReviewNote = note;
|
||||
nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId;
|
||||
nomination.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
seasonId,
|
||||
request.CategoryId,
|
||||
null,
|
||||
null,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
@@ -158,6 +159,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
candidate.SeasonId,
|
||||
request.CategoryId,
|
||||
candidateId,
|
||||
candidate.StreamerIdentityId,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
|
||||
@@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = request.ViewerRangeMin,
|
||||
ViewerRangeMax = request.ViewerRangeMax,
|
||||
};
|
||||
|
||||
db.Categories.Add(category);
|
||||
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = request.ViewerRangeMin;
|
||||
category.ViewerRangeMax = request.ViewerRangeMax;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
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> UpdateSeasonSubcategoryTemplates(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateSeasonSubcategoryTemplatesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateSubcategoryTemplatesRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
var blockedRemovals = await FindBlockedSubcategoryRemovalsAsync(db, categories, templates, context.RequestAborted);
|
||||
if (blockedRemovals.Length > 0)
|
||||
{
|
||||
var firstBlocked = blockedRemovals[0];
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = $"Unterkategorie \"{firstBlocked.SubcategoryName}\" kann nicht entfernt werden, weil darunter noch {firstBlocked.CandidateCount} Kandidaten und {firstBlocked.NominationCount} Nominierungen haengen.",
|
||||
blockedSubcategories = blockedRemovals,
|
||||
});
|
||||
}
|
||||
|
||||
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-templates.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Unterkategorien für {season.Year} wurden aktualisiert.",
|
||||
new { seasonId, templateCount = templates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, templateCount = templates.Length });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Lege zuerst mindestens eine globale Unterkategorie an." });
|
||||
}
|
||||
|
||||
var groupName = request.GroupName.Trim();
|
||||
if (categories.Any(item => string.Equals(item.GroupName, groupName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
categories.Add(new Category
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
GroupName = groupName,
|
||||
Name = templates[index].Name,
|
||||
Slug = BuildCategorySlug(groupName, templates[index].Slug),
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = templates[index].ViewerRangeMin,
|
||||
ViewerRangeMax = templates[index].ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var category in categories.Where(item => item.Id == 0))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.create",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {groupName} wurde angelegt.",
|
||||
new { seasonId, groupName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
var normalizedCurrentName = groupName.Trim();
|
||||
var groupCategories = categories
|
||||
.Where(item => string.Equals(item.GroupName, normalizedCurrentName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var targetName = request.GroupName.Trim();
|
||||
if (!string.Equals(normalizedCurrentName, targetName, StringComparison.OrdinalIgnoreCase)
|
||||
&& categories.Any(item => string.Equals(item.GroupName, targetName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
foreach (var category in groupCategories)
|
||||
{
|
||||
category.GroupName = targetName;
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedCurrentName} wurde aktualisiert.",
|
||||
new { seasonId, from = normalizedCurrentName, to = targetName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName = targetName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var normalizedGroupName = groupName.Trim();
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName.ToLower() == normalizedGroupName.ToLower())
|
||||
.ToListAsync(context.RequestAborted);
|
||||
if (categories.Count == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categoryIds = categories.Select(item => item.Id).ToArray();
|
||||
var candidates = await db.Candidates
|
||||
.Where(item => categoryIds.Contains(item.CategoryId))
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
|
||||
db.Categories.RemoveRange(categories);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.delete",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedGroupName} wurde gelöscht.",
|
||||
new { seasonId, removedCategories = categories.Count, removedCandidates = candidates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, seasonId, groupName = normalizedGroupName });
|
||||
}
|
||||
|
||||
private static void SyncCategoryGroupsToTemplates(
|
||||
AwardsDbContext db,
|
||||
Season season,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates)
|
||||
{
|
||||
var orderedGroups = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var items = group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList();
|
||||
var sample = items[0];
|
||||
return new
|
||||
{
|
||||
GroupName = sample.GroupName.Trim(),
|
||||
Description = sample.Description.Trim(),
|
||||
SortOrder = items.Min(item => item.SortOrder),
|
||||
MaxNomineesPerUser = sample.MaxNomineesPerUser,
|
||||
Items = items,
|
||||
};
|
||||
})
|
||||
.OrderBy(group => group.SortOrder)
|
||||
.ThenBy(group => group.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var nextSortOrder = 1;
|
||||
foreach (var group in orderedGroups)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
var template = templates[index];
|
||||
var category = FindReusableCategoryForTemplate(group.Items, template, group.GroupName, usedCategories)
|
||||
?? new Category { SeasonId = season.Id };
|
||||
usedCategories.Add(category);
|
||||
|
||||
category.GroupName = group.GroupName;
|
||||
category.Name = template.Name;
|
||||
category.Slug = BuildCategorySlug(group.GroupName, template.Slug);
|
||||
category.Description = group.Description;
|
||||
category.MaxNomineesPerUser = group.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||
category.SortOrder = nextSortOrder++;
|
||||
|
||||
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
categories.Add(category);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var staleCategory in FindStaleCategoriesAfterTemplateSync(group.Items, templates, group.GroupName))
|
||||
{
|
||||
RemoveCategoryWithCandidates(db, staleCategory);
|
||||
categories.Remove(staleCategory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveCategoryWithCandidates(AwardsDbContext db, Category category)
|
||||
{
|
||||
if (category.Id > 0)
|
||||
{
|
||||
var candidates = db.Candidates.Where(item => item.CategoryId == category.Id).ToArray();
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
db.Categories.Remove(category);
|
||||
}
|
||||
|
||||
private static async Task<BlockedSubcategoryRemoval[]> FindBlockedSubcategoryRemovalsAsync(
|
||||
AwardsDbContext db,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var staleCategories = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(group => FindStaleCategoriesAfterTemplateSync(
|
||||
group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(),
|
||||
templates,
|
||||
group.Key))
|
||||
.Where(item => item.Id > 0)
|
||||
.ToArray();
|
||||
|
||||
if (staleCategories.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
|
||||
var candidateCounts = await db.Candidates
|
||||
.Where(item => staleCategoryIds.Contains(item.CategoryId))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
var nominationCounts = await db.Nominations
|
||||
.Where(item =>
|
||||
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value)))
|
||||
.GroupBy(item => item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)
|
||||
? item.CategoryId!.Value
|
||||
: item.SuggestedCategoryId!.Value)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
|
||||
return staleCategories
|
||||
.Select(category => new BlockedSubcategoryRemoval(
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Slug,
|
||||
candidateCounts.GetValueOrDefault(category.Id),
|
||||
nominationCounts.GetValueOrDefault(category.Id)))
|
||||
.Where(item => item.CandidateCount > 0 || item.NominationCount > 0)
|
||||
.OrderBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.SubcategoryName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Category? FindReusableCategoryForTemplate(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting template,
|
||||
string groupName,
|
||||
HashSet<Category>? usedCategories = null)
|
||||
{
|
||||
usedCategories ??= [];
|
||||
var expectedSlug = BuildCategorySlug(groupName, template.Slug);
|
||||
var normalizedTemplateSlug = SeasonSubcategoryTemplateSettings.Slugify(template.Slug);
|
||||
|
||||
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, expectedSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, normalizedTemplateSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& item.Slug.EndsWith($"-{normalizedTemplateSlug}", StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static Category[] FindStaleCategoriesAfterTemplateSync(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
string groupName)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var reusableCategory = FindReusableCategoryForTemplate(categories, template, groupName, usedCategories);
|
||||
if (reusableCategory is not null)
|
||||
{
|
||||
usedCategories.Add(reusableCategory);
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
.Where(item => !usedCategories.Contains(item))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IResult? ValidateSubcategoryTemplatesRequest(UpdateSeasonSubcategoryTemplatesRequest request)
|
||||
{
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Mindestens eine Unterkategorie ist erforderlich." });
|
||||
}
|
||||
|
||||
var duplicateNames = templates
|
||||
.GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateNames)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Namen mehrfach verwenden." });
|
||||
}
|
||||
|
||||
var duplicateSlugs = templates
|
||||
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateSlugs)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Slug mehrfach verwenden." });
|
||||
}
|
||||
|
||||
foreach (var template in templates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Name ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(template.Slug) || template.Slug.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Slug ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (template.ViewerRangeMax is not null
|
||||
&& template.ViewerRangeMin is not null
|
||||
&& template.ViewerRangeMax < template.ViewerRangeMin)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer-Range Ende muss groesser oder gleich dem Start sein." });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult? ValidateCategoryGroupRequest(UpsertCategoryGroupRequest request)
|
||||
{
|
||||
var groupName = request.GroupName.Trim();
|
||||
var description = request.Description.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > 80)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Hauptkategorie ist erforderlich und muss unter 80 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (description.Length > 400)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Beschreibung muss unter 400 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (request.SortOrder is < 1 or > 200)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Die Reihenfolge muss zwischen 1 und 200 liegen." });
|
||||
}
|
||||
|
||||
if (request.MaxNomineesPerUser is < 1 or > 10)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das Nominierungs-Limit muss zwischen 1 und 10 liegen." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record BlockedSubcategoryRemoval(
|
||||
string GroupName,
|
||||
string SubcategoryName,
|
||||
string Slug,
|
||||
int CandidateCount,
|
||||
int NominationCount);
|
||||
|
||||
private static string BuildCategorySlug(string groupName, string templateSlug)
|
||||
{
|
||||
var groupSlug = SeasonSubcategoryTemplateSettings.Slugify(groupName);
|
||||
var detailSlug = SeasonSubcategoryTemplateSettings.Slugify(templateSlug);
|
||||
return string.IsNullOrWhiteSpace(groupSlug) ? detailSlug : $"{groupSlug}-{detailSlug}";
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
}
|
||||
|
||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings));
|
||||
|
||||
var season = new Season
|
||||
{
|
||||
@@ -45,6 +49,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
ReviewEndsAt = request.ReviewEndsAt,
|
||||
ShowDate = request.ShowDate,
|
||||
ShowStartsAt = request.ShowStartsAt,
|
||||
SubcategoryTemplatesJson = "[]",
|
||||
WorkflowRulesJson = initialWorkflowRulesJson,
|
||||
};
|
||||
|
||||
db.Seasons.Add(season);
|
||||
@@ -72,6 +78,13 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
copiedCategoryCount = sourceCategories.Length;
|
||||
var sourceSeason = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||
season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson;
|
||||
season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson)
|
||||
? initialWorkflowRulesJson
|
||||
: sourceSeason.WorkflowRulesJson;
|
||||
foreach (var category in sourceCategories)
|
||||
{
|
||||
db.Categories.Add(new Category
|
||||
@@ -83,6 +96,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = category.Description,
|
||||
SortOrder = category.SortOrder,
|
||||
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||
|
||||
var candidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new AdminCandidateItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.Platform,
|
||||
item.NominationTally,
|
||||
item.AcceptanceStatus,
|
||||
item.AcceptanceNote,
|
||||
item.ClipCompilationUrl,
|
||||
@@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
|
||||
season,
|
||||
categoryRows.Select(category => new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
}));
|
||||
var visibleCategoryRows = categoryRows
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
|
||||
new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
},
|
||||
subcategoryTemplateSettings))
|
||||
.ToArray();
|
||||
|
||||
var categories = categoryRows
|
||||
var categories = visibleCategoryRows
|
||||
.Select(category => new AdminCategoryItemDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
@@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominations = await db.Nominations
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||
|
||||
var pendingNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? string.Empty,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = await db.Nominations
|
||||
var pendingNominations = pendingNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
|
||||
|
||||
var reviewedNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = reviewedNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var resultItems = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CandidateId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.Platform))
|
||||
@@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
season.ReviewEndsAt,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
subcategoryTemplates,
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations,
|
||||
pendingNominationGroups,
|
||||
reviewedNominations,
|
||||
settings?.TrackingReviewNotes ?? string.Empty,
|
||||
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||
resultItems,
|
||||
clipSubmissions));
|
||||
}
|
||||
|
||||
private sealed record AdminNominationRow(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string? CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? HoursStreamed,
|
||||
int? HoursWatched,
|
||||
int? PeakViewers,
|
||||
int? FollowersGained,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
string TrackingFlagsJson,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
string? CandidateDisplayName,
|
||||
string? ReviewNote,
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
private static AdminNominationReviewItemDto ToNominationReviewItem(
|
||||
AdminNominationRow item,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
|
||||
.Select(ToTrackingFlagHitDto)
|
||||
.ToArray();
|
||||
|
||||
return new(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
ResolveCategoryGroupName(item, categoryRows),
|
||||
ResolveCategoryName(item, categoryRows),
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryName,
|
||||
item.StreamerIdentityId,
|
||||
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
|
||||
trackingFlags.Any(flag => flag.RequiresManualReview),
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(item, trackingRules),
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
item.CandidateDisplayName,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt);
|
||||
}
|
||||
|
||||
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
|
||||
IEnumerable<AdminNominationRow> rows,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
rows
|
||||
.GroupBy(item => new
|
||||
{
|
||||
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
|
||||
IdentityKey = item.StreamerIdentityId.HasValue
|
||||
? $"identity:{item.StreamerIdentityId.Value}"
|
||||
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
|
||||
})
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
|
||||
var representative = ordered
|
||||
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
|
||||
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
|
||||
.ThenByDescending(item => item.AvgViewers.HasValue)
|
||||
.First();
|
||||
var trackerStatus = ResolveGroupTrackerStatus(ordered);
|
||||
var trackingFlags = ordered
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.GroupBy(item => item.Key)
|
||||
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
|
||||
.ToArray();
|
||||
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
|
||||
return new AdminNominationReviewGroupDto(
|
||||
representative.Id,
|
||||
ordered.Select(item => item.Id).ToArray(),
|
||||
ResolveCategoryGroupName(representative, categoryRows),
|
||||
ResolveNominationDisplayName(representative),
|
||||
representative.StreamUrl,
|
||||
representative.ResolvedChannel,
|
||||
representative.ResolvedPlatform,
|
||||
representative.AvgViewers,
|
||||
representative.SuggestedCategoryId,
|
||||
representative.SuggestedCategoryName,
|
||||
representative.StreamerIdentityId,
|
||||
trackerStatus,
|
||||
representative.TrackerCheckedAt,
|
||||
ResolveGroupTrackingReviewStatus(ordered),
|
||||
requiresManualReview,
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(representative, trackingRules),
|
||||
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
|
||||
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
|
||||
ordered.Max(item => item.TrackingReviewedAt),
|
||||
ordered.Length,
|
||||
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
|
||||
ordered.First().CreatedAt,
|
||||
ordered.Last().CreatedAt);
|
||||
})
|
||||
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
|
||||
.ThenByDescending(item => item.RequiresManualReview)
|
||||
.ThenByDescending(item => item.NominationTally)
|
||||
.ThenByDescending(item => item.UniqueSubmitterCount)
|
||||
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
|
||||
.ThenByDescending(item => item.AvgViewers ?? -1)
|
||||
.ThenByDescending(item => item.LastSubmittedAt)
|
||||
.ToArray();
|
||||
|
||||
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
|
||||
item.ResolvedChannel
|
||||
?? item.CandidateText
|
||||
?? item.StreamUrl
|
||||
?? "Name im Review festlegen";
|
||||
|
||||
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackerStatus
|
||||
?? "pending";
|
||||
}
|
||||
|
||||
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackingReviewStatus
|
||||
?? "clear";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
|
||||
{
|
||||
return item.CategoryGroupName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.GroupName ?? "Unbekannte Hauptkategorie";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryName))
|
||||
{
|
||||
return item.CategoryName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
|
||||
}
|
||||
|
||||
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
|
||||
new(
|
||||
flag.Key,
|
||||
flag.Label,
|
||||
flag.Severity,
|
||||
flag.Description,
|
||||
flag.RequiresManualReview,
|
||||
flag.BlocksApproval,
|
||||
flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
|
||||
AdminNominationRow row,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
trackingRules.ImportantMetrics
|
||||
.Concat(trackingRules.OptionalMetrics)
|
||||
.Where(metric => metric.Enabled && metric.ShowInReview)
|
||||
.Select(metric => new AdminTrackingMetricStateDto(
|
||||
metric.Key,
|
||||
metric.Label,
|
||||
metric.RequiredForAutoClassification,
|
||||
metric.SourceSupport,
|
||||
MetricPresent(metric, row),
|
||||
MetricValue(metric, row),
|
||||
metric.Description,
|
||||
metric.WindowKey,
|
||||
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||
.ToArray();
|
||||
|
||||
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
|
||||
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
|
||||
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
|
||||
_ => "manuell",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (metric.TopCount.HasValue)
|
||||
{
|
||||
parts.Add($"Top {metric.TopCount.Value}");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategorySharePercent.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategoryHours.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
|
||||
{
|
||||
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
|
||||
}
|
||||
|
||||
if (metric.IgnoredCategories.Length > 0)
|
||||
{
|
||||
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
|
||||
}
|
||||
|
||||
return parts.Count > 0
|
||||
? string.Join(" · ", parts)
|
||||
: "Top-Kategorien manuell pruefen";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategory")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminSeasonSubcategoryTemplates")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("CreateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||
.WithName("CreateAdminCandidate")
|
||||
@@ -56,11 +72,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("DeleteAdminResult")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/workflow-rules", GetWorkflowRules)
|
||||
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/workflow-rules", UpdateWorkflowRules)
|
||||
group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
|
||||
@@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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)
|
||||
@@ -145,13 +161,16 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
|
||||
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(settings);
|
||||
return WorkflowRuleSettings.Read(season, settings);
|
||||
}
|
||||
|
||||
private static IResult CreateWorkflowRuleError(string message) =>
|
||||
@@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
int? streamerIdentityId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
@@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
|
||||
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))
|
||||
@@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new CandidateRuleSnapshot(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus))
|
||||
@@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
@@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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);
|
||||
@@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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)
|
||||
@@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
var categoriesWithCandidates = await db.Candidates
|
||||
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()
|
||||
.CountAsync(cancellationToken);
|
||||
.Count();
|
||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||
if (emptyCategories > 0)
|
||||
{
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
||||
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)
|
||||
@@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
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))
|
||||
@@ -364,6 +484,21 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
||||
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
||||
@@ -56,11 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.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)
|
||||
|
||||
@@ -41,6 +41,22 @@ public static class AdminSiteSettingsEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOptionalFeatureSettings")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/tracking-rules", GetTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules", UpdateTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/source", UpdateTrackingSource)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingSource")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingReviewNotes")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName,
|
||||
settings.HostTagline,
|
||||
settings.NewsletterUrl,
|
||||
settings.ShareXUrl,
|
||||
settings.ShareDiscordUrl,
|
||||
settings.PrivacyEmail,
|
||||
settings.PrivacyPolicyContent,
|
||||
settings.PrivacyPolicyUpdatedBy,
|
||||
@@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ShowactsUrl,
|
||||
settings.ShowactsContent,
|
||||
SeasonMappings.ReadSocialLinks(settings),
|
||||
SeasonMappings.ReadFaqItems(settings)));
|
||||
SeasonMappings.ReadFaqItems(settings),
|
||||
settings.ShowactFormSchemaJson ?? "[]"));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSiteSettings(
|
||||
@@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||
settings.HostTagline = request.HostTagline.Trim();
|
||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
||||
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||
@@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ShowactsContent = request.ShowactsContent.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
socialLinks = [];
|
||||
|
||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|
||||
@@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
normalizedUrls = new PublicSiteUrlSettings
|
||||
{
|
||||
NewsletterUrl = newsletterUrl,
|
||||
ShareXUrl = shareXUrl,
|
||||
ShareDiscordUrl = shareDiscordUrl,
|
||||
ImprintUrl = imprintUrl,
|
||||
ContactUrl = contactUrl,
|
||||
SponsorsUrl = sponsorsUrl,
|
||||
@@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
private sealed class PublicSiteUrlSettings
|
||||
{
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ShareXUrl { get; set; } = string.Empty;
|
||||
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||
public string ImprintUrl { get; set; } = string.Empty;
|
||||
public string ContactUrl { get; set; } = string.Empty;
|
||||
public string SponsorsUrl { get; set; } = string.Empty;
|
||||
@@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTrackingRules(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToTrackingRulesResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingRules(
|
||||
HttpContext context,
|
||||
UpdateTrackingRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var applyResult = ApplyTrackingRulesRequest(settings, request);
|
||||
if (applyResult is not null)
|
||||
{
|
||||
return applyResult;
|
||||
}
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Rules wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl,
|
||||
manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes,
|
||||
importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled),
|
||||
optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled),
|
||||
flagCount = after.Flags.Count(item => item.Enabled),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingSource(
|
||||
HttpContext context,
|
||||
UpdateTrackingSourceRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
var updatedRules = rules with
|
||||
{
|
||||
Source = new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? rules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview),
|
||||
};
|
||||
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules);
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-source.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Source wurde aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeBaseUrl = before.Source.BaseUrl,
|
||||
afterBaseUrl = after.Source.BaseUrl,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingReviewNotes(
|
||||
HttpContext context,
|
||||
UpdateTrackingReviewNotesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var beforeNotes = settings.TrackingReviewNotes ?? string.Empty;
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with
|
||||
{
|
||||
Source = rules.Source with
|
||||
{
|
||||
ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview,
|
||||
},
|
||||
});
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-review-notes.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Review Notes wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeLength = beforeNotes.Length,
|
||||
afterLength = settings.TrackingReviewNotes.Length,
|
||||
beforeShowInReview = before.Source.ShowManualReviewNotesInReview,
|
||||
afterShowInReview = after.Source.ShowManualReviewNotesInReview,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
||||
HttpContext context,
|
||||
UpdateOptionalFeatureSettingsRequest request,
|
||||
@@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
}
|
||||
|
||||
var before = ToOptionalFeatureSettingsResponse(settings);
|
||||
var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt);
|
||||
if (scheduleValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = scheduleValidationError });
|
||||
}
|
||||
|
||||
var disabledMessage = NormalizeOptionalFeatureText(
|
||||
request.ClipSubmissionDisabledMessage,
|
||||
FallbackClipSubmissionDisabledMessage,
|
||||
@@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
||||
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
||||
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
||||
settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt;
|
||||
settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt;
|
||||
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
||||
settings.SponsorsVisible = request.SponsorsVisible;
|
||||
|
||||
@@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints
|
||||
? FallbackClipSubmissionDisabledMessage
|
||||
: settings.ClipSubmissionDisabledMessage,
|
||||
settings.ShowactApplicationsEnabled,
|
||||
settings.ShowactApplicationStartsAt,
|
||||
settings.ShowactApplicationEndsAt,
|
||||
ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: settings.ShowactApplicationDisabledMessage,
|
||||
@@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
||||
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
||||
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
||||
AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt);
|
||||
AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt);
|
||||
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
||||
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
||||
return changes.ToArray();
|
||||
@@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||
usesDatabaseDemo,
|
||||
@@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
twitchSettings.ClientSecretSet,
|
||||
twitchSettings.RedirectUri,
|
||||
twitchSettings.Scope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||
@@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
||||
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
||||
var twitchScope = request.TwitchScope.Trim();
|
||||
if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." });
|
||||
}
|
||||
|
||||
var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours);
|
||||
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||
|
||||
@@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.TwitchClientSecret,
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope);
|
||||
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
|
||||
|
||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||
? newPassword
|
||||
@@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
"operational-settings.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
|
||||
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
settings.DemoLoginEnabled,
|
||||
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
||||
settings.SessionIdleTimeoutHours,
|
||||
settings.MaintenanceModeEnabled,
|
||||
changes,
|
||||
},
|
||||
@@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||
@@ -576,6 +781,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
||||
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
||||
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
||||
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
|
||||
|
||||
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
||||
{
|
||||
@@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
@@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints
|
||||
string RedirectUri,
|
||||
string Scope,
|
||||
bool Configured);
|
||||
|
||||
private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request)
|
||||
{
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var currentRules = TrackingRulesSettings.Read(settings);
|
||||
var configuration = new TrackingRulesConfiguration(
|
||||
new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? currentRules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview),
|
||||
(request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray());
|
||||
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration);
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings)
|
||||
{
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
return new AdminTrackingRulesResponse(
|
||||
new AdminTrackingSourceDto(
|
||||
rules.Source.ProviderKey,
|
||||
"TwitchTracker Basic API",
|
||||
TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl),
|
||||
rules.Source.NotesSummary,
|
||||
rules.Source.ShowManualReviewNotesInReview),
|
||||
rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(),
|
||||
settings.TrackingReviewNotes ?? string.Empty);
|
||||
}
|
||||
|
||||
private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
rule.WindowKey,
|
||||
rule.AutoSupportedWindowKeys,
|
||||
rule.ProviderFieldKey,
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
rule.IgnoredCategories,
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d),
|
||||
(rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(),
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
(rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage)
|
||||
{
|
||||
normalizedValue = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
var trimmed = (rawValue ?? string.Empty).Trim();
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,33 +8,41 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
|
||||
private static async Task<IResult> GetWorkflowRules(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(season, settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWorkflowRules(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateWorkflowRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = WorkflowRuleSettings.Read(settings);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var before = WorkflowRuleSettings.Read(season, settings);
|
||||
var mergedRules = WorkflowRuleSettings.Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
@@ -51,8 +59,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(settings);
|
||||
season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(season, settings);
|
||||
var changes = after
|
||||
.Select(rule =>
|
||||
{
|
||||
@@ -72,10 +80,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"workflow-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Workflow-Regeln wurden aktualisiert.",
|
||||
new { changes },
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.",
|
||||
new { seasonId = season.Id, season.Year, changes },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
|
||||
@@ -12,6 +12,7 @@ public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> DemoLogin(
|
||||
HttpContext context,
|
||||
IHostEnvironment environment,
|
||||
AwardsDbContext db,
|
||||
IConfiguration configuration,
|
||||
DemoLoginRequest request,
|
||||
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
|
||||
var password = request.Password ?? string.Empty;
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var databaseDemoConfigured = settings is not null
|
||||
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
|
||||
&& settings.DemoLoginManagedByDatabase;
|
||||
|
||||
string twitchUserId;
|
||||
string displayName;
|
||||
bool credentialsMatch;
|
||||
var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (databaseDemoConfigured && settings is not null)
|
||||
{
|
||||
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
|
||||
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||
displayName = settings.DemoLoginDisplayName.Trim();
|
||||
|
||||
if (!credentialsMatch
|
||||
&& environment.IsDevelopment()
|
||||
&& IsDemoLoginEnabled(configuration)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var configuredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuredLogin)
|
||||
|| string.IsNullOrWhiteSpace(configuredPassword)
|
||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(displayName))
|
||||
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Demo login is not fully configured." },
|
||||
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
|
||||
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
configuredLogin,
|
||||
configuredEmail,
|
||||
twitchUserId,
|
||||
displayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
|
||||
twitchUserId = twitchUserId.Trim();
|
||||
displayName = displayName.Trim();
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
|
||||
if (!credentialsMatch)
|
||||
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
|
||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||
@@ -36,6 +36,7 @@ public static partial class AuthEndpoints
|
||||
|
||||
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
UserSession session,
|
||||
bool mustChangePassword = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
|
||||
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||
var sessionRole = teamMember?.Role ?? session.Role;
|
||||
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
|
||||
return new(
|
||||
session.SessionToken,
|
||||
session.TwitchUserId,
|
||||
teamMember?.DisplayName ?? session.DisplayName,
|
||||
AdminRoles.Normalize(sessionRole),
|
||||
permissionKeys,
|
||||
sessionIdleTimeoutHours,
|
||||
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||
teamMember?.Login,
|
||||
teamMember?.BoundTwitchUserId,
|
||||
|
||||
@@ -44,7 +44,7 @@ public static partial class AuthEndpoints
|
||||
context.RequestAborted);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(
|
||||
@@ -92,7 +92,7 @@ public static partial class AuthEndpoints
|
||||
session.Role = AdminRoles.Normalize(member.Role);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static string BuildTeamSessionId(string login) =>
|
||||
|
||||
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||
false,
|
||||
false,
|
||||
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
|
||||
currentSessionUsesBoundTwitch,
|
||||
currentSessionUsesBoundTwitch
|
||||
? null
|
||||
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
@@ -8,6 +11,8 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
|
||||
|
||||
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
@@ -45,13 +50,19 @@ public static partial class PublicEndpoints
|
||||
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private static async Task<IResult> CreateShowactApplication(
|
||||
HttpContext context,
|
||||
CreateShowactApplicationRequest request,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (settings is null || !settings.ShowactApplicationsEnabled)
|
||||
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
@@ -67,55 +78,137 @@ public static partial class PublicEndpoints
|
||||
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||
}
|
||||
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
||||
var hasDynamicForm = schema.Count > 0;
|
||||
|
||||
if (hasDynamicForm)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
Dictionary<string, string> responses;
|
||||
try
|
||||
{
|
||||
responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson)
|
||||
? new Dictionary<string, string>()
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(request.FieldResponsesJson, ShowactJsonOptions) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
MergeLegacyShowactFieldsIntoResponses(schema, responses, request);
|
||||
|
||||
var dynamicValidationError = ValidateShowactResponses(schema, responses);
|
||||
if (dynamicValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = dynamicValidationError });
|
||||
}
|
||||
|
||||
var artistNameField = schema.FirstOrDefault(f => f.IsArtistName);
|
||||
var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt";
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions);
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName[..Math.Min(artistName.Length, 120)],
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = technicalNotes,
|
||||
ReferenceUrl = referenceUrl,
|
||||
FieldResponsesJson = fieldResponsesJson,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
// Legacy fixed-fields path
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrValidEmail(contactEmail))
|
||||
{
|
||||
return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
private sealed class ShowactFieldDefinition
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||
[JsonPropertyName("required")] public bool Required { get; set; }
|
||||
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
|
||||
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
|
||||
}
|
||||
|
||||
private static string NormalizePublicText(string? value, int maxLength)
|
||||
@@ -128,4 +221,167 @@ public static partial class PublicEndpoints
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||
|
||||
private static bool IsBlankOrValidEmail(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
|
||||
|
||||
private static bool IsBlankOrValidJsonObject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(value);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Object;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateShowactResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses)
|
||||
{
|
||||
foreach (var field in schema)
|
||||
{
|
||||
var value = responses.TryGetValue(field.Id, out var rawValue)
|
||||
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
|
||||
: string.Empty;
|
||||
responses[field.Id] = value;
|
||||
|
||||
if (field.Required && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
|
||||
? $"Bitte bestaetige: {field.Label}"
|
||||
: $"{field.Label} ist erforderlich.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
|
||||
{
|
||||
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
|
||||
{
|
||||
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
|
||||
{
|
||||
if (field.MaxLength > 0)
|
||||
{
|
||||
return Math.Min(field.MaxLength, 2000);
|
||||
}
|
||||
|
||||
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
|
||||
}
|
||||
|
||||
private static void MergeLegacyShowactFieldsIntoResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses,
|
||||
CreateShowactApplicationRequest request)
|
||||
{
|
||||
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
|
||||
MergeResponseValue(artistField, request.ArtistName, responses, 120);
|
||||
|
||||
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
|
||||
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
|
||||
|
||||
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
|
||||
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
|
||||
|
||||
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
|
||||
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
|
||||
|
||||
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
|
||||
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
|
||||
|
||||
var performanceField = schema.FirstOrDefault(field =>
|
||||
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|
||||
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
|
||||
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
|
||||
|
||||
var descriptionField = schema.FirstOrDefault(field =>
|
||||
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
|
||||
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|
||||
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
|
||||
MergeResponseValue(descriptionField, request.Description, responses, 1000);
|
||||
|
||||
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
|
||||
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
|
||||
}
|
||||
|
||||
private static void MergeResponseValue(
|
||||
ShowactFieldDefinition? field,
|
||||
string? requestValue,
|
||||
IDictionary<string, string> responses,
|
||||
int maxLength)
|
||||
{
|
||||
if (field is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = NormalizePublicText(requestValue, maxLength);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
responses[field.Id] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Id.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Label.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,14 @@ public static partial class PublicEndpoints
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
IRiskRuleService riskRuleService,
|
||||
NominationEnrichmentService nominationEnrichmentService)
|
||||
{
|
||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
||||
|
||||
if (submittedNominations.Length is 0 or > 3)
|
||||
if (submittedNominations.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
|
||||
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
||||
@@ -35,7 +36,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var distinctStreamUrls = submittedNominations
|
||||
.Select(item => item.StreamUrl)
|
||||
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
@@ -65,16 +66,37 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
||||
}
|
||||
|
||||
var category = await db.Categories
|
||||
.Include(item => item.Season)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||
|
||||
if (category is null)
|
||||
if (season is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||
return Results.BadRequest(new { message = "The selected season does not exist." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
|
||||
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted);
|
||||
if (string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var groupCategories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories);
|
||||
if (submittedNominations.Length > maxNomineesPerUser)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||
if (nominationSeasonResolution.Result is not null)
|
||||
{
|
||||
return nominationSeasonResolution.Result;
|
||||
@@ -89,15 +111,16 @@ public static partial class PublicEndpoints
|
||||
var submitterId = submitterIdResult.SubmitterId!;
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
item.SeasonId == season.Id
|
||||
&& item.CategoryGroupName == categoryGroupName
|
||||
&& item.SubmittedByTwitchId == submitterId
|
||||
&& item.Status == "pending");
|
||||
|
||||
var records = submittedNominations.Select(nomination => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
SeasonId = season.Id,
|
||||
CategoryId = null,
|
||||
CategoryGroupName = categoryGroupName,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||
@@ -106,6 +129,11 @@ public static partial class PublicEndpoints
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}).ToArray();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||
@@ -126,21 +154,21 @@ public static partial class PublicEndpoints
|
||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
resubmittedNominationRule.Severity,
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
||||
requestMetadata,
|
||||
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
@@ -152,7 +180,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
||||
return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 });
|
||||
}
|
||||
|
||||
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||
@@ -191,4 +219,44 @@ public static partial class PublicEndpoints
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeNominationUrlForCompare(string value) =>
|
||||
value.Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
|
||||
{
|
||||
var configuredLimit = groupCategories
|
||||
.Select(item => item.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return Math.Clamp(configuredLimit, 1, 10);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveCategoryGroupNameAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
CreateNominationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var categoryGroupName = request.CategoryGroupName?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!request.CategoryId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,18 @@ public static partial class PublicEndpoints
|
||||
{
|
||||
return Results.Problem("Site settings are missing.");
|
||||
}
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
|
||||
|
||||
var canExposeCurrentSeasonWinners = CanExposeCurrentSeasonWinners(season.CurrentPhase);
|
||||
|
||||
var winnerPreviewRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Season)
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result =>
|
||||
result.Season.Year < season.Year
|
||||
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||
.OrderByDescending(result => result.Season.Year)
|
||||
.ThenBy(result => result.CategoryName)
|
||||
.Take(8)
|
||||
@@ -65,7 +71,9 @@ public static partial class PublicEndpoints
|
||||
|
||||
var archiveYearRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result =>
|
||||
result.Season.Year < season.Year
|
||||
|| canExposeCurrentSeasonWinners && result.Season.Year == season.Year)
|
||||
.GroupBy(result => result.Season.Year)
|
||||
.Select(group => new
|
||||
{
|
||||
@@ -83,6 +91,33 @@ public static partial class PublicEndpoints
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var featuredCategories = publicCategories
|
||||
.GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group
|
||||
.OrderBy(category => category.SortOrder)
|
||||
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var first = ordered[0];
|
||||
var maxNomineesPerUser = ordered
|
||||
.Select(category => category.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return new FeaturedCategoryDto(
|
||||
first.Id,
|
||||
first.GroupName,
|
||||
first.GroupName,
|
||||
first.Description,
|
||||
maxNomineesPerUser);
|
||||
})
|
||||
.OrderBy(category => publicCategories
|
||||
.Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase))
|
||||
.Min(item => item.SortOrder))
|
||||
.ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var response = new OverviewResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
@@ -100,20 +135,15 @@ public static partial class PublicEndpoints
|
||||
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
||||
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||
},
|
||||
publicCategories
|
||||
.Select(category => new FeaturedCategoryDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser))
|
||||
.ToArray(),
|
||||
featuredCategories,
|
||||
winnerPreviewItems,
|
||||
archiveYears,
|
||||
new PublicSiteContentDto(
|
||||
siteSettings.HostDisplayName,
|
||||
siteSettings.HostTagline,
|
||||
siteSettings.NewsletterUrl,
|
||||
siteSettings.ShareXUrl,
|
||||
siteSettings.ShareDiscordUrl,
|
||||
siteSettings.PrivacyEmail,
|
||||
siteSettings.PrivacyPolicyContent,
|
||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||
@@ -124,11 +154,14 @@ public static partial class PublicEndpoints
|
||||
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||
: siteSettings.ClipSubmissionDisabledMessage,
|
||||
siteSettings.ShowactApplicationsEnabled,
|
||||
showactApplicationsOpenNow,
|
||||
siteSettings.ShowactApplicationStartsAt,
|
||||
siteSettings.ShowactApplicationEndsAt,
|
||||
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: siteSettings.ShowactApplicationDisabledMessage,
|
||||
siteSettings.SponsorsVisible),
|
||||
siteSettings.SponsorsVisible,
|
||||
siteSettings.ShowactFormSchemaJson ?? "[]"),
|
||||
SeasonMappings.ReadFaqItems(siteSettings));
|
||||
|
||||
return Results.Ok(response);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Common;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||
@@ -54,6 +57,8 @@ public static partial class PublicEndpoints
|
||||
category.GroupName,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
category.Candidates
|
||||
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(candidate =>
|
||||
|
||||
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
|
||||
|
||||
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||
{
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
if (!usesDatabaseDemo)
|
||||
{
|
||||
return IsDemoLoginEnabled(configuration);
|
||||
|
||||
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.CategoryGroupName,
|
||||
item.Status,
|
||||
Nominee = item.CandidateId != null
|
||||
? item.Candidate!.DisplayName
|
||||
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
|
||||
var groupedNominations = nominations
|
||||
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
|
||||
.Select(group => new UserNominationStateDto(
|
||||
group.Key,
|
||||
group.Key.CategoryId,
|
||||
group.Key.CategoryGroupName,
|
||||
group.Select(item => item.Nominee!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()))
|
||||
|
||||
Reference in New Issue
Block a user