From 92dd6f7432e481538b59a55c705bc9968adcc650 Mon Sep 17 00:00:00 2001 From: AzuTear Date: Wed, 17 Jun 2026 12:01:57 +0200 Subject: [PATCH] Add risk center and editable submission flows --- Backend/Contracts/AdminContracts.cs | 27 +- Backend/Data/AwardsDbContext.cs | 26 ++ Backend/Data/OperationalTablesBootstrapper.cs | 53 +++ Backend/Domain/AdminAuditEntry.cs | 13 + Backend/Domain/RiskFlag.cs | 20 + Backend/Domain/UserSession.cs | 2 + Backend/Program.cs | 381 +++++++++++++++++- README.md | 2 + frontend/src/lib/api.ts | 6 + frontend/src/stores/awards.ts | 32 ++ frontend/src/types/awards.ts | 25 ++ frontend/src/views/AdminView.vue | 94 +++++ 12 files changed, 661 insertions(+), 20 deletions(-) create mode 100644 Backend/Data/OperationalTablesBootstrapper.cs create mode 100644 Backend/Domain/AdminAuditEntry.cs create mode 100644 Backend/Domain/RiskFlag.cs diff --git a/Backend/Contracts/AdminContracts.cs b/Backend/Contracts/AdminContracts.cs index 93c57a2..959d31c 100644 --- a/Backend/Contracts/AdminContracts.cs +++ b/Backend/Contracts/AdminContracts.cs @@ -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 Metrics, IEnumerable Activities, - IEnumerable TopCategories); + IEnumerable TopCategories, + IEnumerable RiskFlags, + IEnumerable 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); diff --git a/Backend/Data/AwardsDbContext.cs b/Backend/Data/AwardsDbContext.cs index 1d3b1ee..7bbbe50 100644 --- a/Backend/Data/AwardsDbContext.cs +++ b/Backend/Data/AwardsDbContext.cs @@ -13,6 +13,8 @@ public sealed class AwardsDbContext(DbContextOptions options) : public DbSet VoteBallots => Set(); public DbSet VoteEntries => Set(); public DbSet UserSessions => Set(); + public DbSet RiskFlags => Set(); + public DbSet AdminAuditEntries => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -62,6 +64,30 @@ public sealed class AwardsDbContext(DbContextOptions 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(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(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); diff --git a/Backend/Data/OperationalTablesBootstrapper.cs b/Backend/Data/OperationalTablesBootstrapper.cs new file mode 100644 index 0000000..1a36144 --- /dev/null +++ b/Backend/Data/OperationalTablesBootstrapper.cs @@ -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); + """); +} diff --git a/Backend/Domain/AdminAuditEntry.cs b/Backend/Domain/AdminAuditEntry.cs new file mode 100644 index 0000000..ef13626 --- /dev/null +++ b/Backend/Domain/AdminAuditEntry.cs @@ -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; } +} diff --git a/Backend/Domain/RiskFlag.cs b/Backend/Domain/RiskFlag.cs new file mode 100644 index 0000000..3573964 --- /dev/null +++ b/Backend/Domain/RiskFlag.cs @@ -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; } +} diff --git a/Backend/Domain/UserSession.cs b/Backend/Domain/UserSession.cs index c4f4fde..c45abd6 100644 --- a/Backend/Domain/UserSession.cs +++ b/Backend/Domain/UserSession.cs @@ -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; } diff --git a/Backend/Program.cs b/Backend/Program.cs index 74a0153..4a924f5 100644 --- a/Backend/Program.cs +++ b/Backend/Program.cs @@ -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 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(); diff --git a/README.md b/README.md index 32e34a6..d6f42f4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The frontend uses a lightweight local session flow for development: - Sign in from the header - `Viewer Login` unlocks nomination and voting - `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 @@ -85,3 +86,4 @@ curl -X POST http://localhost:5084/api/auth/dev-login \ - Session endpoints live under `/api/auth/*` - 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 +- The admin dashboard now includes a lightweight risk center and audit log for suspicious submit patterns and reviewed admin actions diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b2cad07..5fa9af3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -96,6 +96,12 @@ export const api = { '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 } diff --git a/frontend/src/stores/awards.ts b/frontend/src/stores/awards.ts index 4d1915c..5965c53 100644 --- a/frontend/src/stores/awards.ts +++ b/frontend/src/stores/awards.ts @@ -103,6 +103,31 @@ const fallbackAdmin: AdminDashboardResponse = { { category: 'Bestes Live Event', votes: 132550 }, { 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[] = [ @@ -158,6 +183,8 @@ const emptyAdmin: AdminDashboardResponse = { metrics: [], activities: [], topCategories: [], + riskFlags: [], + auditEntries: [], } const emptyAdminSeasons: AdminSeasonListItem[] = [] @@ -269,5 +296,10 @@ export const useAwardsStore = defineStore('awards', { await this.loadAdmin() return result }, + async resolveRiskFlag(riskFlagId: number, status = 'resolved') { + const result = await api.resolveRiskFlag(riskFlagId, status) + await this.loadAdmin() + return result + }, }, }) diff --git a/frontend/src/types/awards.ts b/frontend/src/types/awards.ts index 6303ae4..9d9cfbd 100644 --- a/frontend/src/types/awards.ts +++ b/frontend/src/types/awards.ts @@ -89,10 +89,35 @@ export interface AdminTopCategory { 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 { metrics: AdminMetric[] activities: AdminActivity[] topCategories: AdminTopCategory[] + riskFlags: AdminRiskFlag[] + auditEntries: AdminAuditEntry[] } export interface AdminSeasonListItem { diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 4779132..b8fa885 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -16,6 +16,7 @@ const seasonSaving = ref(false) const categorySaving = ref(null) const candidateSaving = ref(null) const reviewSaving = ref(null) +const riskSaving = ref(null) const adminMessage = ref('') const adminError = ref('') @@ -71,6 +72,8 @@ onMounted(async () => { const metrics = computed(() => store.admin.metrics) const activities = computed(() => store.admin.activities) const topCategories = computed(() => store.admin.topCategories) +const riskFlags = computed(() => store.admin.riskFlags) +const auditEntries = computed(() => store.admin.auditEntries) const seasons = computed(() => store.adminSeasons) const seasonDetail = computed(() => store.adminSeasonDetail) const categoryOptions = computed(() => @@ -262,6 +265,21 @@ async function rejectNomination(nominationId: number) { 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 + } +}