using Backend.Contracts; 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(AwardsDbContext db, HttpContext context) { var canViewAuditIp = CanViewAuditIp(context); var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent); if (currentSeason is null) { return Results.NotFound(); } var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id); var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id); var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id); var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.Status == "pending"); var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open"); var topCategoryNames = await db.VoteEntries .AsNoTracking() .Where(item => item.Ballot.SeasonId == currentSeason.Id) .Select(item => item.Category.Name) .ToListAsync(); var topCategories = topCategoryNames .GroupBy(name => name) .Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count())) .OrderByDescending(item => item.Votes) .Take(5) .ToArray(); var riskFlags = await db.RiskFlags .AsNoTracking() .Where(item => item.Status == "open") .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(3) .Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min.")) .ToArray(); return Results.Ok(new AdminDashboardResponse( new[] { new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"), new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"), new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"), new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf"), new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"), }, activityItems, topCategories, riskFlagDtos, auditEntries)); } 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); }