using Backend.Contracts; using Backend.Common; using Backend.Data; using Backend.Security; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; public static class AdminDashboardEndpoints { public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group) { group.MapGet("/dashboard", GetDashboard) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Dashboard)) .WithName("GetAdminDashboard") .WithOpenApi(); group.MapGet("/audit-entries", GetAuditEntries) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Audit)) .WithName("GetAdminAuditEntries") .WithOpenApi(); return group; } private static async Task GetDashboard(int? seasonId, AwardsDbContext db, HttpContext context) { var canViewAuditIp = CanViewAuditIp(context); var selectedSeason = seasonId.HasValue ? await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.Id == seasonId.Value) : await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent); if (selectedSeason is null) { return Results.NotFound(); } var selectedSeasonId = selectedSeason.Id; var phaseKey = SeasonMappings.NormalizePhaseKey(selectedSeason.CurrentPhase); var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId); var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == selectedSeasonId); var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == selectedSeasonId); var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId && item.Status == "pending"); var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open" && (item.SeasonId == selectedSeasonId || item.SeasonId == null)); var globalRiskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open" && item.SeasonId == null); var topCategories = phaseKey == "nomination" ? await BuildTopNominationCategoriesAsync(db, selectedSeasonId) : await BuildTopVotingCategoriesAsync(db, selectedSeasonId); var riskFlags = await db.RiskFlags .AsNoTracking() .Where(item => item.Status == "open" && (item.SeasonId == selectedSeasonId || item.SeasonId == null)) .OrderByDescending(item => item.CreatedAt) .Take(8) .ToArrayAsync(); var riskFlagDtos = riskFlags.Select(AdminRiskFlagMappings.ToDto).ToArray(); var auditEntries = await db.AdminAuditEntries .AsNoTracking() .OrderByDescending(item => item.CreatedAt) .Take(8) .Select(item => new AdminAuditEntryDto( item.Id, item.AdminTwitchUserId, item.ActionType, item.EntityType, item.EntityId, item.Summary, item.CreatedAt, item.MetadataJson, canViewAuditIp ? item.CreatedFromIp : null, item.UserAgent)) .ToArrayAsync(); var activityItems = auditEntries .Take(6) .Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min.")) .ToArray(); return Results.Ok(new AdminDashboardResponse( selectedSeason.Id, selectedSeason.Year, selectedSeason.Name, selectedSeason.IsCurrent, new[] { new AdminMetricDto("Nominierungen", nominationCount, $"Gespeicherte Einreichungen im Award-Jahr {selectedSeason.Year}"), new AdminMetricDto("Stimmen", voteCount, $"Abgegebene Stimmen im Award-Jahr {selectedSeason.Year}"), new AdminMetricDto("Kategorien", categoryCount, $"Aktive Kategorien im Award-Jahr {selectedSeason.Year}"), new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf in diesem Jahr"), new AdminMetricDto( "Risikohinweise", riskFlagCount, globalRiskFlagCount > 0 ? $"Offene Hinweise fuer {selectedSeason.Year}, inklusive {globalRiskFlagCount} globaler Hinweise" : $"Offene Hinweise fuer {selectedSeason.Year}"), }, activityItems, topCategories, riskFlagDtos, auditEntries)); } private static async Task BuildTopVotingCategoriesAsync(AwardsDbContext db, int seasonId) { var categoryNames = await db.VoteEntries .AsNoTracking() .Where(item => item.Ballot.SeasonId == seasonId) .Select(item => item.Category.Name) .ToListAsync(); return categoryNames .GroupBy(name => name) .Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Stimmen")) .OrderByDescending(item => item.Value) .Take(5) .ToArray(); } private static async Task BuildTopNominationCategoriesAsync(AwardsDbContext db, int seasonId) { var nominationCategories = await db.Nominations .AsNoTracking() .Where(item => item.SeasonId == seasonId) .Select(item => new { CategoryName = item.Category != null ? item.Category.Name : null, item.CategoryGroupName, }) .ToListAsync(); return nominationCategories .Select(item => string.IsNullOrWhiteSpace(item.CategoryName) ? string.IsNullOrWhiteSpace(item.CategoryGroupName) ? "Ohne Kategorie" : item.CategoryGroupName : item.CategoryName) .GroupBy(name => name) .Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Nominierungen")) .OrderByDescending(item => item.Value) .Take(5) .ToArray(); } private static async Task GetAuditEntries( int? limit, string? query, string? admin, string? action, string? entityType, DateTimeOffset? from, DateTimeOffset? to, string? cursor, AwardsDbContext db, HttpContext context) { var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500); var search = query?.Trim(); var canViewAuditIp = CanViewAuditIp(context); var auditQuery = db.AdminAuditEntries.AsNoTracking(); if (!TryDecodeAuditCursor(cursor, out var decodedCursor)) { return Results.BadRequest(new { message = "Invalid audit cursor." }); } if (!string.IsNullOrWhiteSpace(search)) { var pattern = $"%{search}%"; auditQuery = auditQuery.Where(item => EF.Functions.ILike(item.AdminTwitchUserId, pattern) || EF.Functions.ILike(item.ActionType, pattern) || EF.Functions.ILike(item.EntityType, pattern) || EF.Functions.ILike(item.EntityId, pattern) || EF.Functions.ILike(item.Summary, pattern) || EF.Functions.ILike(item.MetadataJson, pattern) || EF.Functions.ILike(item.UserAgent, pattern) || (canViewAuditIp && EF.Functions.ILike(item.CreatedFromIp, pattern))); } if (!string.IsNullOrWhiteSpace(admin)) { var normalizedAdmin = admin.Trim(); auditQuery = auditQuery.Where(item => item.AdminTwitchUserId == normalizedAdmin); } if (!string.IsNullOrWhiteSpace(action)) { var normalizedAction = action.Trim(); auditQuery = auditQuery.Where(item => item.ActionType == normalizedAction); } if (!string.IsNullOrWhiteSpace(entityType)) { var normalizedEntityType = entityType.Trim(); auditQuery = auditQuery.Where(item => item.EntityType == normalizedEntityType); } if (from.HasValue) { auditQuery = auditQuery.Where(item => item.CreatedAt >= from.Value); } if (to.HasValue) { auditQuery = auditQuery.Where(item => item.CreatedAt <= to.Value); } var totalCount = await auditQuery.CountAsync(); if (decodedCursor is not null) { auditQuery = auditQuery.Where(item => item.CreatedAt < decodedCursor.CreatedAt || (item.CreatedAt == decodedCursor.CreatedAt && item.Id < decodedCursor.Id)); } var page = await auditQuery .OrderByDescending(item => item.CreatedAt) .ThenByDescending(item => item.Id) .Take(normalizedLimit + 1) .Select(item => new AdminAuditEntryDto( item.Id, item.AdminTwitchUserId, item.ActionType, item.EntityType, item.EntityId, item.Summary, item.CreatedAt, item.MetadataJson, canViewAuditIp ? item.CreatedFromIp : null, item.UserAgent)) .ToArrayAsync(); var hasMore = page.Length > normalizedLimit; var entries = page.Take(normalizedLimit).ToArray(); var nextCursor = hasMore && entries.Length > 0 ? EncodeAuditCursor(entries[^1]) : null; return Results.Ok(new AdminAuditEntriesResponse( entries, totalCount, entries.Length, nextCursor, normalizedLimit)); } private static string EncodeAuditCursor(AdminAuditEntryDto entry) => $"{entry.CreatedAt.UtcTicks}:{entry.Id}"; private static bool CanViewAuditIp(HttpContext context) => AdminRoles.IsPrivilegedFullControlRole(context.GetCurrentSession()?.Role); private static bool TryDecodeAuditCursor(string? cursor, out AuditCursor? decodedCursor) { decodedCursor = null; if (string.IsNullOrWhiteSpace(cursor)) { return true; } var parts = cursor.Split(':', 2); if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks) || !int.TryParse(parts[1], out var id)) { return false; } try { decodedCursor = new AuditCursor(new DateTimeOffset(ticks, TimeSpan.Zero), id); return true; } catch (ArgumentOutOfRangeException) { return false; } } private sealed record AuditCursor(DateTimeOffset CreatedAt, int Id); }