229 lines
8.3 KiB
C#
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);
|
|
}
|