286 lines
11 KiB
C#
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);
|
|
}
|