using Backend.Contracts; using Backend.Common; using Backend.Data; using Backend.Security; using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; public static partial class AdminModerationEndpoints { private static async Task GetRiskFlags( int? limit, int? offset, string? status, string? severity, string? query, bool? reviewedOnly, AwardsDbContext db) { var normalizedLimit = Math.Clamp(limit ?? 25, 1, 100); var normalizedOffset = Math.Max(offset ?? 0, 0); var normalizedStatus = NormalizeRiskFlagStatus(status, allowAll: true); if (normalizedStatus is null) { return Results.BadRequest(new { message = "Risk flag status must be open, resolved, dismissed or all." }); } var riskQuery = db.RiskFlags.AsNoTracking(); if (reviewedOnly == true) { riskQuery = riskQuery.Where(item => item.Status != "open"); } if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase)) { var normalizedSeverity = severity.Trim().ToLowerInvariant(); if (normalizedSeverity is not ("low" or "medium" or "high")) { return Results.BadRequest(new { message = "Risk flag severity must be low, medium, high or all." }); } riskQuery = riskQuery.Where(item => item.Severity == normalizedSeverity); } if (normalizedStatus != "all") { riskQuery = riskQuery.Where(item => item.Status == normalizedStatus); } if (!string.IsNullOrWhiteSpace(query)) { var pattern = $"%{query.Trim()}%"; riskQuery = riskQuery.Where(item => EF.Functions.ILike(item.Source, pattern) || EF.Functions.ILike(item.Type, pattern) || EF.Functions.ILike(item.Severity, pattern) || EF.Functions.ILike(item.Status, pattern) || EF.Functions.ILike(item.Summary, pattern) || (item.TwitchUserId != null && EF.Functions.ILike(item.TwitchUserId, pattern)) || EF.Functions.ILike(item.CreatedFromIp, pattern) || (item.ReviewNote != null && EF.Functions.ILike(item.ReviewNote, pattern)) || EF.Functions.ILike(item.MetadataJson, pattern)); } var totalCount = await riskQuery.CountAsync(); var severityCounts = await riskQuery .GroupBy(item => item.Severity) .Select(group => new AdminRiskCountDto(group.Key, group.Count())) .ToArrayAsync(); var statusCounts = await riskQuery .GroupBy(item => item.Status) .Select(group => new AdminRiskCountDto(group.Key, group.Count())) .ToArrayAsync(); var flags = await riskQuery .OrderByDescending(item => item.CreatedAt) .Skip(normalizedOffset) .Take(normalizedLimit) .ToArrayAsync(); var items = flags.Select(AdminRiskFlagMappings.ToDto).ToArray(); return Results.Ok(new AdminRiskFlagsResponse( items, totalCount, items.Length, normalizedOffset, normalizedLimit, normalizedOffset + items.Length < totalCount, severityCounts, statusCounts)); } private static async Task ResolveRiskFlag( HttpContext context, int riskFlagId, ResolveRiskFlagRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId); if (riskFlag is null) { return Results.NotFound(); } var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false); if (normalizedStatus is null) { return Results.BadRequest(new { message = "Risk flag status must be open, resolved or dismissed." }); } var previousStatus = riskFlag.Status; var previousReviewNote = riskFlag.ReviewNote; var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote); riskFlag.Status = normalizedStatus; if (normalizedStatus == "open") { riskFlag.ReviewedAt = null; riskFlag.ReviewedByTwitchId = null; riskFlag.ReviewNote = null; } else { riskFlag.ReviewedAt = DateTimeOffset.UtcNow; riskFlag.ReviewedByTwitchId = session.TwitchUserId; riskFlag.ReviewNote = normalizedReviewNote; } adminAuditService.AddEntry( session.TwitchUserId, "risk.resolve", "risk-flag", riskFlag.Id.ToString(), $"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.", new { riskFlag.Type, riskFlag.Source, changes = BuildRiskResolutionChanges(previousStatus, riskFlag.Status, previousReviewNote, riskFlag.ReviewNote), }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status }); } private static async Task BulkResolveRiskFlags( HttpContext context, BulkResolveRiskFlagsRequest? request, AwardsDbContext db, IAdminAuditService adminAuditService) { if (request is null || request.RiskFlagIds is null) { return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht mindestens einen Risikohinweis." }); } var session = AdminEndpointConventions.CurrentSession(context); var normalizedIds = request.RiskFlagIds .Distinct() .Take(100) .ToArray(); if (normalizedIds.Length == 0) { return Results.BadRequest(new { message = "Bitte waehle mindestens einen Risikohinweis aus." }); } var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false); if (normalizedStatus is null || normalizedStatus == "open") { return Results.BadRequest(new { message = "Bulk-Verarbeitung unterstuetzt erledigt oder verworfen." }); } var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote); if (string.IsNullOrWhiteSpace(normalizedReviewNote)) { return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht eine Review-Notiz." }); } var riskFlags = await db.RiskFlags .Where(item => normalizedIds.Contains(item.Id)) .ToArrayAsync(context.RequestAborted); if (riskFlags.Length != normalizedIds.Length) { return Results.BadRequest(new { message = "Mindestens ein Risikohinweis wurde nicht gefunden." }); } if (riskFlags.Any(item => item.Severity != "low")) { return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Low-Severity-Hinweise erlaubt." }); } if (riskFlags.Any(item => item.Status != "open")) { return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Hinweise erlaubt." }); } var reviewedAt = DateTimeOffset.UtcNow; foreach (var riskFlag in riskFlags) { riskFlag.Status = normalizedStatus; riskFlag.ReviewNote = normalizedReviewNote; riskFlag.ReviewedAt = reviewedAt; riskFlag.ReviewedByTwitchId = session.TwitchUserId; } adminAuditService.AddEntry( session.TwitchUserId, "risk.bulk-resolve", "risk-flag", string.Join(",", normalizedIds), $"{normalizedIds.Length} Low-Risk Flags wurden als {normalizedStatus} markiert.", new { status = normalizedStatus, count = normalizedIds.Length, riskFlagIds = normalizedIds, changes = new[] { new { field = "status", label = "Status", from = "open", to = normalizedStatus, sensitive = false, }, new { field = "reviewNote", label = "Review-Notiz", from = "keine Notiz", to = "Notiz vorhanden", sensitive = true, }, }, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, count = normalizedIds.Length, status = normalizedStatus }); } private static async Task GetRiskRules(AwardsDbContext db) { var settings = await db.SiteSettings .AsNoTracking() .FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } return Results.Ok(new AdminRiskRulesResponse(RiskRuleSettings.Read(settings).Select(ToRiskRuleDto).ToArray())); } private static async Task UpdateRiskRules( HttpContext context, UpdateRiskRulesRequest 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 before = RiskRuleSettings.Read(settings); var mergedRules = RiskRuleSettings.Defaults .Select(defaultRule => { var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key); return requestRule is null ? defaultRule : new RiskRuleSetting( defaultRule.Key, defaultRule.Label, requestRule.Enabled, requestRule.Threshold, requestRule.WindowMinutes, requestRule.Severity, defaultRule.Description); }) .ToArray(); settings.RiskRulesJson = RiskRuleSettings.Serialize(mergedRules); var after = RiskRuleSettings.Read(settings); var changes = after .Select(rule => { var previous = before.First(item => item.Key == rule.Key); return new { field = rule.Key, label = rule.Label, from = $"{previous.Enabled}/{previous.Threshold}/{previous.WindowMinutes}/{previous.Severity}", to = $"{rule.Enabled}/{rule.Threshold}/{rule.WindowMinutes}/{rule.Severity}", sensitive = false, }; }) .Where(change => change.from != change.to) .ToArray(); adminAuditService.AddEntry( session.TwitchUserId, "risk-rules.update", "site-settings", settings.Id.ToString(), "Risk-Regeln wurden aktualisiert.", new { changes }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new AdminRiskRulesResponse(after.Select(ToRiskRuleDto).ToArray())); } private static AdminRiskRuleDto ToRiskRuleDto(RiskRuleSetting rule) => new(rule.Key, rule.Label, rule.Enabled, rule.Threshold, rule.WindowMinutes, rule.Severity, rule.Description); private static string? NormalizeReviewNote(string? reviewNote) { if (string.IsNullOrWhiteSpace(reviewNote)) { return null; } var trimmedReviewNote = reviewNote.Trim(); return trimmedReviewNote.Length <= 500 ? trimmedReviewNote : trimmedReviewNote[..500]; } private static object[] BuildRiskResolutionChanges( string previousStatus, string currentStatus, string? previousReviewNote, string? currentReviewNote) { var changes = new List { new { field = "status", label = "Status", from = previousStatus, to = currentStatus, sensitive = false, }, }; if (!string.Equals(previousReviewNote, currentReviewNote, StringComparison.Ordinal)) { changes.Add(new { field = "reviewNote", label = "Review-Notiz", from = string.IsNullOrWhiteSpace(previousReviewNote) ? "keine Notiz" : "Notiz vorhanden", to = string.IsNullOrWhiteSpace(currentReviewNote) ? "keine Notiz" : "Notiz vorhanden", sensitive = true, }); } return changes.ToArray(); } private static string? NormalizeRiskFlagStatus(string? status, bool allowAll) { var normalizedStatus = string.IsNullOrWhiteSpace(status) ? "open" : status.Trim().ToLowerInvariant(); return normalizedStatus switch { "open" or "resolved" or "dismissed" => normalizedStatus, "all" when allowAll => normalizedStatus, _ => null, }; } }