Add risk center and editable submission flows

This commit is contained in:
AzuTear
2026-06-17 12:01:57 +02:00
parent 670259a983
commit 92dd6f7432
12 changed files with 661 additions and 20 deletions
+26 -1
View File
@@ -6,10 +6,33 @@ public sealed record AdminActivityDto(string Label, string Age);
public sealed record AdminTopCategoryDto(string Category, int Votes); 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( public sealed record AdminDashboardResponse(
IEnumerable<AdminMetricDto> Metrics, IEnumerable<AdminMetricDto> Metrics,
IEnumerable<AdminActivityDto> Activities, IEnumerable<AdminActivityDto> Activities,
IEnumerable<AdminTopCategoryDto> TopCategories); IEnumerable<AdminTopCategoryDto> TopCategories,
IEnumerable<AdminRiskFlagDto> RiskFlags,
IEnumerable<AdminAuditEntryDto> AuditEntries);
public sealed record AdminSeasonListItemDto( public sealed record AdminSeasonListItemDto(
int Id, int Id,
@@ -76,3 +99,5 @@ public sealed record ApproveNominationRequest(
string? DisplayName, string? DisplayName,
string? ChannelSlug, string? ChannelSlug,
string? Platform); string? Platform);
public sealed record ResolveRiskFlagRequest(string Status);
+26
View File
@@ -13,6 +13,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>(); public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>(); public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
public DbSet<UserSession> UserSessions => Set<UserSession>(); public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder) 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.TwitchUserId).HasMaxLength(120);
entity.Property(item => item.DisplayName).HasMaxLength(120); entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.Role).HasMaxLength(40); 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); 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);
""");
}
+13
View File
@@ -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; }
}
+20
View File
@@ -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; }
}
+2
View File
@@ -7,6 +7,8 @@ public sealed class UserSession
public string TwitchUserId { get; set; } = string.Empty; public string TwitchUserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty;
public string Role { get; set; } = "viewer"; 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 CreatedAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; } public DateTimeOffset LastSeenAt { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
+358 -15
View File
@@ -2,6 +2,7 @@ using Backend.Contracts;
using Backend.Data; using Backend.Data;
using Backend.Domain; using Backend.Domain;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration["VTSA_POSTGRES"] var connectionString = builder.Configuration["VTSA_POSTGRES"]
@@ -58,6 +59,79 @@ static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsD
return session; 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()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
@@ -86,15 +160,18 @@ using (var scope = app.Services.CreateScope())
try try
{ {
await SessionBootstrapper.EnsureAsync(db); await SessionBootstrapper.EnsureAsync(db);
await OperationalTablesBootstrapper.EnsureAsync(db);
} }
catch 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 var session = new UserSession
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -102,11 +179,32 @@ app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext
TwitchUserId = request.TwitchUserId.Trim(), TwitchUserId = request.TwitchUserId.Trim(),
DisplayName = request.DisplayName.Trim(), DisplayName = request.DisplayName.Trim(),
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer", Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
CreatedFromIp = createdFromIp,
UserAgent = userAgent,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
LastSeenAt = DateTimeOffset.UtcNow, LastSeenAt = DateTimeOffset.UtcNow,
IsActive = true, 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); db.UserSessions.Add(session);
await db.SaveChangesAsync(); 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." }); 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 var records = distinctNominees.Select(name => new Nomination
{ {
SeasonId = category.SeasonId, SeasonId = category.SeasonId,
@@ -348,9 +465,44 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
}); });
await db.Nominations.AddRangeAsync(records); 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(); 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") .WithName("CreateNomination")
.WithOpenApi(); .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." }); return Results.BadRequest(new { message = "A logged in user is required to submit votes." });
} }
var ballot = new VoteBallot 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, SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId, SubmittedByTwitchId = submitterId,
SubmittedAt = DateTimeOffset.UtcNow,
Status = "submitted",
}; };
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 ballot.Entries = request.Entries.Select(entry => new VoteEntry
{ {
CategoryId = entry.CategoryId, CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId, CandidateId = entry.CandidateId,
}).ToList(); }).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(); 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") .WithName("CreateVote")
.WithOpenApi(); .WithOpenApi();
@@ -433,6 +653,43 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
.Take(5) .Take(5)
.ToArray(); .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( var response = new AdminDashboardResponse(
new[] 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("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"), new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
}, },
new[] activityItems,
{ topCategories,
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."), riskFlags,
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."), auditEntries);
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
},
topCategories);
return Results.Ok(response); return Results.Ok(response);
}) })
@@ -594,6 +848,14 @@ app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
} }
season.IsCurrent = request.IsCurrent; 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(); await db.SaveChangesAsync();
return Results.Ok(new { saved = true, seasonId = season.Id }); 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); 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(); await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id }); 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.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser; 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(); await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id }); 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); 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(); await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id }); 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.ChannelSlug = request.ChannelSlug.Trim();
candidate.Platform = request.Platform.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(); await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id }); 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.CandidateId = candidate.Id;
nomination.CandidateText = null; 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(); await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null }); 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.CandidateText = null;
nomination.CandidateId = null; nomination.CandidateId = null;
AddAuditEntry(
db,
session.TwitchUserId,
"nomination.reject",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde verworfen.");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true }); 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") .WithName("RejectAdminNomination")
.WithOpenApi(); .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(); app.Run();
+2
View File
@@ -28,6 +28,7 @@ The frontend uses a lightweight local session flow for development:
- Sign in from the header - Sign in from the header
- `Viewer Login` unlocks nomination and voting - `Viewer Login` unlocks nomination and voting
- `Admin Login` unlocks the admin routes and management views - `Admin Login` unlocks the admin routes and management views
- Voting and nominations can be resubmitted; the backend updates the existing user state instead of blindly duplicating submissions
## Backend ## Backend
@@ -85,3 +86,4 @@ curl -X POST http://localhost:5084/api/auth/dev-login \
- Session endpoints live under `/api/auth/*` - Session endpoints live under `/api/auth/*`
- Database connectivity and pending migrations are exposed at `/api/health/database` - Database connectivity and pending migrations are exposed at `/api/health/database`
- Current frontend store falls back to static seed-like data if the API is unavailable - Current frontend store falls back to static seed-like data if the API is unavailable
- The admin dashboard now includes a lightweight risk center and audit log for suspicious submit patterns and reviewed admin actions
+6
View File
@@ -96,6 +96,12 @@ export const api = {
'POST', 'POST',
{}, {},
), ),
resolveRiskFlag: (riskFlagId: number, status = 'resolved') =>
sendJson<{ saved: boolean; riskFlagId: number; status: string }>(
`/api/admin/risk-flags/${riskFlagId}/resolve`,
'POST',
{ status },
),
} }
export { AUTH_TOKEN_KEY } export { AUTH_TOKEN_KEY }
+32
View File
@@ -103,6 +103,31 @@ const fallbackAdmin: AdminDashboardResponse = {
{ category: 'Bestes Live Event', votes: 132550 }, { category: 'Bestes Live Event', votes: 132550 },
{ category: 'Clip des Jahres', votes: 98210 }, { category: 'Clip des Jahres', votes: 98210 },
], ],
riskFlags: [
{
id: 1,
source: 'vote',
type: 'rapid_vote_updates',
severity: 'high',
status: 'open',
summary: 'Mehrere Voting-Aenderungen in kurzer Zeit erkannt.',
twitchUserId: 'demo_user',
createdFromIp: '127.0.0.1',
createdAt: '2026-06-17T08:40:00Z',
metadataJson: '{"recentVoteSubmissions":3}',
},
],
auditEntries: [
{
id: 1,
adminTwitchUserId: 'jayuhime_admin',
actionType: 'category.update',
entityType: 'category',
entityId: '1',
summary: 'Kategorie VTuber des Jahres wurde aktualisiert.',
createdAt: '2026-06-17T08:32:00Z',
},
],
} }
const fallbackAdminSeasons: AdminSeasonListItem[] = [ const fallbackAdminSeasons: AdminSeasonListItem[] = [
@@ -158,6 +183,8 @@ const emptyAdmin: AdminDashboardResponse = {
metrics: [], metrics: [],
activities: [], activities: [],
topCategories: [], topCategories: [],
riskFlags: [],
auditEntries: [],
} }
const emptyAdminSeasons: AdminSeasonListItem[] = [] const emptyAdminSeasons: AdminSeasonListItem[] = []
@@ -269,5 +296,10 @@ export const useAwardsStore = defineStore('awards', {
await this.loadAdmin() await this.loadAdmin()
return result return result
}, },
async resolveRiskFlag(riskFlagId: number, status = 'resolved') {
const result = await api.resolveRiskFlag(riskFlagId, status)
await this.loadAdmin()
return result
},
}, },
}) })
+25
View File
@@ -89,10 +89,35 @@ export interface AdminTopCategory {
votes: number votes: number
} }
export interface AdminRiskFlag {
id: number
source: string
type: string
severity: string
status: string
summary: string
twitchUserId: string | null
createdFromIp: string
createdAt: string
metadataJson: string
}
export interface AdminAuditEntry {
id: number
adminTwitchUserId: string
actionType: string
entityType: string
entityId: string
summary: string
createdAt: string
}
export interface AdminDashboardResponse { export interface AdminDashboardResponse {
metrics: AdminMetric[] metrics: AdminMetric[]
activities: AdminActivity[] activities: AdminActivity[]
topCategories: AdminTopCategory[] topCategories: AdminTopCategory[]
riskFlags: AdminRiskFlag[]
auditEntries: AdminAuditEntry[]
} }
export interface AdminSeasonListItem { export interface AdminSeasonListItem {
+94
View File
@@ -16,6 +16,7 @@ const seasonSaving = ref(false)
const categorySaving = ref<number | 'new' | null>(null) const categorySaving = ref<number | 'new' | null>(null)
const candidateSaving = ref<number | 'new' | null>(null) const candidateSaving = ref<number | 'new' | null>(null)
const reviewSaving = ref<number | null>(null) const reviewSaving = ref<number | null>(null)
const riskSaving = ref<number | null>(null)
const adminMessage = ref('') const adminMessage = ref('')
const adminError = ref('') const adminError = ref('')
@@ -71,6 +72,8 @@ onMounted(async () => {
const metrics = computed(() => store.admin.metrics) const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities) const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories) const topCategories = computed(() => store.admin.topCategories)
const riskFlags = computed(() => store.admin.riskFlags)
const auditEntries = computed(() => store.admin.auditEntries)
const seasons = computed(() => store.adminSeasons) const seasons = computed(() => store.adminSeasons)
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryOptions = computed(() => const categoryOptions = computed(() =>
@@ -262,6 +265,21 @@ async function rejectNomination(nominationId: number) {
reviewSaving.value = null reviewSaving.value = null
} }
} }
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
riskSaving.value = riskFlagId
adminMessage.value = ''
adminError.value = ''
try {
await store.resolveRiskFlag(riskFlagId, status)
adminMessage.value = `Risk Flag ${riskFlagId} wurde als ${status} markiert.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Risk Flag konnte nicht aktualisiert werden.'
} finally {
riskSaving.value = null
}
}
</script> </script>
<template> <template>
@@ -359,6 +377,82 @@ async function rejectNomination(nominationId: number) {
</Card> </Card>
</div> </div>
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risk Center</h2>
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nomination- und Voting-Muster fuer die manuelle Sichtung.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ riskFlags.length }} offen
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="flag in riskFlags"
:key="flag.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ flag.source }} · {{ flag.type }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ flag.summary }}</h3>
<p class="mt-2 text-sm text-slate-500">
{{ flag.twitchUserId || 'unbekannter User' }} · {{ flag.createdFromIp }} · {{ new Date(flag.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-slate-600">
{{ flag.severity }}
</div>
</div>
<pre class="mt-4 overflow-x-auto rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-xs text-slate-600">{{ flag.metadataJson }}</pre>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="riskSaving === flag.id" variant="secondary" @click="resolveRiskFlag(flag.id, 'dismissed')">
{{ riskSaving === flag.id ? 'Speichert ...' : 'Dismiss' }}
</Button>
<Button :disabled="riskSaving === flag.id" @click="resolveRiskFlag(flag.id, 'resolved')">
{{ riskSaving === flag.id ? 'Speichert ...' : 'Resolve' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit Log</h2>
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ auditEntries.length }} Eintraege
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="entry in auditEntries"
:key="entry.id"
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="font-semibold text-slate-800">{{ entry.summary }}</p>
<p class="mt-1 text-sm text-slate-500">
{{ entry.adminTwitchUserId }} · {{ entry.actionType }} · {{ entry.entityType }} {{ entry.entityId }}
</p>
</div>
<p class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</p>
</div>
</div>
</div>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]"> <div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Card class="p-7"> <Card class="p-7">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">