Files
vtuber-awards/Backend/Endpoints/AdminDashboardEndpoints.cs
T
2026-06-25 19:52:46 +02:00

229 lines
8.3 KiB
C#

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<IResult> GetDashboard(AwardsDbContext db)
{
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,
item.CreatedFromIp,
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<IResult> GetAuditEntries(
int? limit,
string? query,
string? admin,
string? action,
string? entityType,
DateTimeOffset? from,
DateTimeOffset? to,
string? cursor,
AwardsDbContext db)
{
var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500);
var search = query?.Trim();
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.CreatedFromIp, pattern) ||
EF.Functions.ILike(item.UserAgent, 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,
item.CreatedFromIp,
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 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);
}