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 AdminModerationEndpoints { private static async Task ApproveNomination( HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { 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) { return Results.NotFound(); } 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 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 == 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 = targetCategory.Id, StreamerIdentityId = nomination.StreamerIdentityId, DisplayName = rawDisplayName, ChannelSlug = channelSlug, Platform = platform, }; db.Candidates.Add(candidate); await db.SaveChangesAsync(context.RequestAborted); } else { candidate.StreamerIdentityId ??= nomination.StreamerIdentityId; candidate.DisplayName = rawDisplayName; if (!string.IsNullOrWhiteSpace(channelSlug)) { candidate.ChannelSlug = channelSlug; } if (!string.IsNullOrWhiteSpace(platform)) { candidate.Platform = platform; } } 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. {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, uniqueViewerCount, nominationIds = relatedNominations.Select(item => item.Id).ToArray(), }); } private static async Task RejectNomination( HttpContext context, int nominationId, RejectNominationRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId); if (nomination is null) { return Results.NotFound(); } 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, "nomination.reject", "nomination", nomination.Id.ToString(), $"Nominierung {nomination.Id} wurde verworfen.", 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, nominationIds = relatedNominations.Select(item => item.Id).ToArray() }); } private static async Task 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 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 FindRelatedPendingNominationsAsync( AwardsDbContext db, Nomination nomination, CancellationToken cancellationToken) => await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken); private static async Task 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 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; } }