Add risk center and editable submission flows
This commit is contained in:
@@ -6,10 +6,33 @@ public sealed record AdminActivityDto(string Label, string Age);
|
||||
|
||||
public sealed record AdminTopCategoryDto(string Category, int Votes);
|
||||
|
||||
public sealed record AdminRiskFlagDto(
|
||||
int Id,
|
||||
string Source,
|
||||
string Type,
|
||||
string Severity,
|
||||
string Status,
|
||||
string Summary,
|
||||
string? TwitchUserId,
|
||||
string CreatedFromIp,
|
||||
DateTimeOffset CreatedAt,
|
||||
string MetadataJson);
|
||||
|
||||
public sealed record AdminAuditEntryDto(
|
||||
int Id,
|
||||
string AdminTwitchUserId,
|
||||
string ActionType,
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string Summary,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AdminDashboardResponse(
|
||||
IEnumerable<AdminMetricDto> Metrics,
|
||||
IEnumerable<AdminActivityDto> Activities,
|
||||
IEnumerable<AdminTopCategoryDto> TopCategories);
|
||||
IEnumerable<AdminTopCategoryDto> TopCategories,
|
||||
IEnumerable<AdminRiskFlagDto> RiskFlags,
|
||||
IEnumerable<AdminAuditEntryDto> AuditEntries);
|
||||
|
||||
public sealed record AdminSeasonListItemDto(
|
||||
int Id,
|
||||
@@ -76,3 +99,5 @@ public sealed record ApproveNominationRequest(
|
||||
string? DisplayName,
|
||||
string? ChannelSlug,
|
||||
string? Platform);
|
||||
|
||||
public sealed record ResolveRiskFlagRequest(string Status);
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
|
||||
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -62,6 +64,30 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.Role).HasMaxLength(40);
|
||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RiskFlag>(entity =>
|
||||
{
|
||||
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
|
||||
entity.Property(item => item.Source).HasMaxLength(80);
|
||||
entity.Property(item => item.Type).HasMaxLength(80);
|
||||
entity.Property(item => item.Severity).HasMaxLength(20);
|
||||
entity.Property(item => item.Status).HasMaxLength(20);
|
||||
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AdminAuditEntry>(entity =>
|
||||
{
|
||||
entity.Property(item => item.AdminTwitchUserId).HasMaxLength(120);
|
||||
entity.Property(item => item.ActionType).HasMaxLength(80);
|
||||
entity.Property(item => item.EntityType).HasMaxLength(80);
|
||||
entity.Property(item => item.EntityId).HasMaxLength(120);
|
||||
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||
});
|
||||
|
||||
SeedData.Apply(modelBuilder);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static class OperationalTablesBootstrapper
|
||||
{
|
||||
public static Task EnsureAsync(AwardsDbContext db) =>
|
||||
db.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
ALTER TABLE "UserSessions"
|
||||
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "UserSessions"
|
||||
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"SeasonId" integer NULL,
|
||||
"TwitchUserId" character varying(120) NULL,
|
||||
"Source" character varying(80) NOT NULL,
|
||||
"Type" character varying(80) NOT NULL,
|
||||
"Severity" character varying(20) NOT NULL,
|
||||
"Status" character varying(20) NOT NULL,
|
||||
"Summary" character varying(240) NOT NULL,
|
||||
"CreatedFromIp" character varying(80) NOT NULL,
|
||||
"UserAgent" character varying(400) NOT NULL,
|
||||
"MetadataJson" text NOT NULL,
|
||||
"ReviewedByTwitchId" character varying(120) NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL,
|
||||
"ReviewedAt" timestamp with time zone NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt"
|
||||
ON "RiskFlags" ("Status", "CreatedAt" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_SeasonId"
|
||||
ON "RiskFlags" ("SeasonId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"AdminTwitchUserId" character varying(120) NOT NULL,
|
||||
"ActionType" character varying(80) NOT NULL,
|
||||
"EntityType" character varying(80) NOT NULL,
|
||||
"EntityId" character varying(120) NOT NULL,
|
||||
"Summary" character varying(240) NOT NULL,
|
||||
"MetadataJson" text NOT NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
|
||||
ON "AdminAuditEntries" ("CreatedAt" DESC);
|
||||
""");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class AdminAuditEntry
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string AdminTwitchUserId { get; set; } = string.Empty;
|
||||
public string ActionType { get; set; } = string.Empty;
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class RiskFlag
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? SeasonId { get; set; }
|
||||
public Season? Season { get; set; }
|
||||
public string? TwitchUserId { get; set; }
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = "medium";
|
||||
public string Status { get; set; } = "open";
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string CreatedFromIp { get; set; } = string.Empty;
|
||||
public string UserAgent { get; set; } = string.Empty;
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
}
|
||||
@@ -7,6 +7,8 @@ public sealed class UserSession
|
||||
public string TwitchUserId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = "viewer";
|
||||
public string CreatedFromIp { get; set; } = string.Empty;
|
||||
public string UserAgent { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
+362
-19
@@ -2,6 +2,7 @@ using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var connectionString = builder.Configuration["VTSA_POSTGRES"]
|
||||
@@ -58,6 +59,79 @@ static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsD
|
||||
return session;
|
||||
}
|
||||
|
||||
static string ReadClientIp(HttpContext context) =>
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
static string ReadUserAgent(HttpContext context)
|
||||
{
|
||||
var value = context.Request.Headers.UserAgent.ToString().Trim();
|
||||
return value.Length > 400 ? value[..400] : value;
|
||||
}
|
||||
|
||||
static void AddAuditEntry(
|
||||
AwardsDbContext db,
|
||||
string adminTwitchUserId,
|
||||
string actionType,
|
||||
string entityType,
|
||||
string entityId,
|
||||
string summary,
|
||||
object? metadata = null)
|
||||
{
|
||||
db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||
{
|
||||
AdminTwitchUserId = adminTwitchUserId,
|
||||
ActionType = actionType,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Summary = summary,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
static async Task AddRiskFlagIfMissingAsync(
|
||||
AwardsDbContext db,
|
||||
int? seasonId,
|
||||
string? twitchUserId,
|
||||
string source,
|
||||
string type,
|
||||
string severity,
|
||||
string summary,
|
||||
string createdFromIp,
|
||||
string userAgent,
|
||||
object? metadata = null)
|
||||
{
|
||||
var threshold = DateTimeOffset.UtcNow.AddHours(-6);
|
||||
var exists = await db.RiskFlags.AnyAsync(item =>
|
||||
item.Status == "open"
|
||||
&& item.Source == source
|
||||
&& item.Type == type
|
||||
&& item.TwitchUserId == twitchUserId
|
||||
&& item.CreatedFromIp == createdFromIp
|
||||
&& item.SeasonId == seasonId
|
||||
&& item.CreatedAt >= threshold);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.RiskFlags.Add(new RiskFlag
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
TwitchUserId = twitchUserId,
|
||||
Source = source,
|
||||
Type = type,
|
||||
Severity = severity,
|
||||
Status = "open",
|
||||
Summary = summary,
|
||||
CreatedFromIp = createdFromIp,
|
||||
UserAgent = userAgent,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
@@ -86,15 +160,18 @@ using (var scope = app.Services.CreateScope())
|
||||
try
|
||||
{
|
||||
await SessionBootstrapper.EnsureAsync(db);
|
||||
await OperationalTablesBootstrapper.EnsureAsync(db);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If the session table bootstrap fails, the rest of the API can still start.
|
||||
// If the operational table bootstrap fails, the rest of the API can still start.
|
||||
}
|
||||
}
|
||||
|
||||
app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext db) =>
|
||||
app.MapPost("/api/auth/dev-login", async (HttpContext context, LoginRequest request, AwardsDbContext db) =>
|
||||
{
|
||||
var createdFromIp = ReadClientIp(context);
|
||||
var userAgent = ReadUserAgent(context);
|
||||
var session = new UserSession
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -102,11 +179,32 @@ app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext
|
||||
TwitchUserId = request.TwitchUserId.Trim(),
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
|
||||
CreatedFromIp = createdFromIp,
|
||||
UserAgent = userAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastSeenAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
var recentSessionsFromIp = await db.UserSessions.CountAsync(item =>
|
||||
item.CreatedFromIp == createdFromIp
|
||||
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-15));
|
||||
|
||||
if (recentSessionsFromIp >= 3)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
null,
|
||||
session.TwitchUserId,
|
||||
"login",
|
||||
"rapid_login_ip",
|
||||
"medium",
|
||||
"Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { recentSessionsFromIp });
|
||||
}
|
||||
|
||||
db.UserSessions.Add(session);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -338,6 +436,25 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
|
||||
return Results.BadRequest(new { message = "A logged in user is required to submit nominations." });
|
||||
}
|
||||
|
||||
var createdFromIp = ReadClientIp(context);
|
||||
var userAgent = ReadUserAgent(context);
|
||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
&& item.SubmittedByTwitchId == submitterId);
|
||||
|
||||
var previousNominations = await db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
&& item.SubmittedByTwitchId == submitterId)
|
||||
.ToArrayAsync();
|
||||
|
||||
if (previousNominations.Length > 0)
|
||||
{
|
||||
db.Nominations.RemoveRange(previousNominations);
|
||||
}
|
||||
|
||||
var records = distinctNominees.Select(name => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
@@ -348,9 +465,44 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
|
||||
});
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var recentNominationVolume = await db.Nominations.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
|
||||
if (existingNominationCount > 0)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
"low",
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { categoryId = category.Id, existingNominationCount });
|
||||
}
|
||||
|
||||
if (recentNominationVolume >= 10)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
"high",
|
||||
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { recentNominationVolume });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name });
|
||||
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name, replacedPrevious = previousNominations.Length > 0 });
|
||||
})
|
||||
.WithName("CreateNomination")
|
||||
.WithOpenApi();
|
||||
@@ -379,24 +531,92 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
|
||||
return Results.BadRequest(new { message = "A logged in user is required to submit votes." });
|
||||
}
|
||||
|
||||
var ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = request.SeasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
Status = "submitted",
|
||||
};
|
||||
var createdFromIp = ReadClientIp(context);
|
||||
var userAgent = ReadUserAgent(context);
|
||||
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
|
||||
var validCandidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id))
|
||||
.Select(item => new { item.Id, item.CategoryId })
|
||||
.ToArrayAsync();
|
||||
|
||||
if (validCandidates.Length != candidateIds.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." });
|
||||
}
|
||||
|
||||
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
|
||||
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
|
||||
}
|
||||
|
||||
var ballot = await db.VoteBallots
|
||||
.Include(item => item.Entries)
|
||||
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
|
||||
|
||||
var isResubmission = ballot is not null;
|
||||
if (ballot is null)
|
||||
{
|
||||
ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = request.SeasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
};
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||
ballot.Entries.Clear();
|
||||
}
|
||||
|
||||
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
ballot.Status = "submitted";
|
||||
ballot.Entries = request.Entries.Select(entry => new VoteEntry
|
||||
{
|
||||
CategoryId = entry.CategoryId,
|
||||
CandidateId = entry.CandidateId,
|
||||
}).ToList();
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot);
|
||||
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
|
||||
if (isResubmission)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
request.SeasonId,
|
||||
submitterId,
|
||||
"vote",
|
||||
"resubmitted_ballot",
|
||||
"low",
|
||||
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { entryCount = request.Entries.Length });
|
||||
}
|
||||
|
||||
if (recentVoteSubmissions >= 3)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
request.SeasonId,
|
||||
submitterId,
|
||||
"vote",
|
||||
"rapid_vote_updates",
|
||||
"high",
|
||||
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { recentVoteSubmissions });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count });
|
||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
|
||||
})
|
||||
.WithName("CreateVote")
|
||||
.WithOpenApi();
|
||||
@@ -433,6 +653,43 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
|
||||
var riskFlags = await db.RiskFlags
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == "open")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(8)
|
||||
.Select(item => new AdminRiskFlagDto(
|
||||
item.Id,
|
||||
item.Source,
|
||||
item.Type,
|
||||
item.Severity,
|
||||
item.Status,
|
||||
item.Summary,
|
||||
item.TwitchUserId,
|
||||
item.CreatedFromIp,
|
||||
item.CreatedAt,
|
||||
item.MetadataJson))
|
||||
.ToArrayAsync();
|
||||
|
||||
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))
|
||||
.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();
|
||||
|
||||
var response = new AdminDashboardResponse(
|
||||
new[]
|
||||
{
|
||||
@@ -441,13 +698,10 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
|
||||
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
|
||||
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."),
|
||||
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."),
|
||||
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
|
||||
},
|
||||
topCategories);
|
||||
activityItems,
|
||||
topCategories,
|
||||
riskFlags,
|
||||
auditEntries);
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
@@ -594,6 +848,14 @@ app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
|
||||
}
|
||||
|
||||
season.IsCurrent = request.IsCurrent;
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"season.update",
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Season {season.Year} wurde aktualisiert.",
|
||||
new { request.CurrentPhase, request.IsCurrent });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, seasonId = season.Id });
|
||||
@@ -627,6 +889,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext c
|
||||
};
|
||||
|
||||
db.Categories.Add(category);
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"category.create",
|
||||
"category",
|
||||
request.Slug.Trim(),
|
||||
$"Kategorie {request.Name.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.GroupName, request.SortOrder });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||
@@ -655,6 +925,14 @@ app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context,
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"category.update",
|
||||
"category",
|
||||
category.Id.ToString(),
|
||||
$"Kategorie {request.Name.Trim()} wurde aktualisiert.",
|
||||
new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||
@@ -686,6 +964,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext c
|
||||
};
|
||||
|
||||
db.Candidates.Add(candidate);
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"candidate.create",
|
||||
"candidate",
|
||||
request.DisplayName.Trim(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.CategoryId, request.Platform });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||
@@ -712,6 +998,14 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
|
||||
candidate.ChannelSlug = request.ChannelSlug.Trim();
|
||||
candidate.Platform = request.Platform.Trim();
|
||||
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"candidate.update",
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||
new { request.CategoryId, request.Platform });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||
@@ -783,6 +1077,14 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpCont
|
||||
|
||||
nomination.CandidateId = candidate.Id;
|
||||
nomination.CandidateText = null;
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"nomination.approve",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||
new { candidateId = candidate.Id, created = existingCandidate is null });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||
@@ -806,6 +1108,13 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
|
||||
|
||||
nomination.CandidateText = null;
|
||||
nomination.CandidateId = null;
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"nomination.reject",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||
@@ -813,4 +1122,38 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapPost("/api/admin/risk-flags/{riskFlagId:int}/resolve", async (HttpContext context, int riskFlagId, ResolveRiskFlagRequest request, AwardsDbContext db) =>
|
||||
{
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
if (session?.Role != "admin")
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
|
||||
if (riskFlag is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
riskFlag.Status = string.IsNullOrWhiteSpace(request.Status) ? "resolved" : request.Status.Trim().ToLowerInvariant();
|
||||
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
|
||||
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"risk.resolve",
|
||||
"risk-flag",
|
||||
riskFlag.Id.ToString(),
|
||||
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
|
||||
new { riskFlag.Type, riskFlag.Source });
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
|
||||
})
|
||||
.WithName("ResolveRiskFlag")
|
||||
.WithOpenApi();
|
||||
|
||||
app.Run();
|
||||
|
||||
Reference in New Issue
Block a user