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
+362 -19
View File
@@ -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();