Files
vtuber-awards/Backend/Endpoints/AdminDashboardEndpoints.cs
AzuTear 441ef2b850
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 53s
Update release notes and deploy workspace
2026-06-29 17:49:54 +02:00

286 lines
11 KiB
C#

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<IResult> 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<AdminTopCategoryDto[]> 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<AdminTopCategoryDto[]> 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<IResult> 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);
}