Compare commits

...

2 Commits

Author SHA1 Message Date
AzuTear 953257bcef Refactor admin panel into categorized navigation 2026-06-17 12:37:17 +02:00
AzuTear 92dd6f7432 Add risk center and editable submission flows 2026-06-17 12:01:57 +02:00
21 changed files with 1468 additions and 586 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; }
+362 -19
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);
SeasonId = request.SeasonId, var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
SubmittedByTwitchId = submitterId, var validCandidates = await db.Candidates
SubmittedAt = DateTimeOffset.UtcNow, .AsNoTracking()
Status = "submitted", .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 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
+2 -2
View File
@@ -25,7 +25,7 @@ const navItems = [
] ]
const currentLabel = computed( const currentLabel = computed(
() => navItems.find((item) => item.to === route.path)?.label ?? 'Awards', () => navItems.find((item) => route.path === item.to || route.path.startsWith(`${item.to}/`))?.label ?? 'Awards',
) )
const visibleNavItems = computed(() => const visibleNavItems = computed(() =>
@@ -103,7 +103,7 @@ async function login(role: 'viewer' | 'admin') {
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700" class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
:class="route.path === item.to ? 'bg-violet-100 text-violet-800' : ''" :class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''"
> >
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import Select from 'primevue/select'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasons = computed(() => store.adminSeasons)
const selectedSeasonId = computed({
get: () => store.adminSelectedSeasonId,
set: async (value) => {
if (!value) return
await store.loadAdminSeasonDetail(value)
},
})
const seasonOptions = computed(() =>
seasons.value.map((season) => ({
label: `${season.year} · ${season.name}`,
value: season.id,
})),
)
</script>
<template>
<div class="flex flex-col gap-4 rounded-[26px] border border-violet-100 bg-white/80 px-5 py-5 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Arbeitskontext</p>
<p class="mt-2 text-sm text-slate-500">Die gewaehlte Season steuert Kategorien, Kandidaten und Review-Queues im gesamten Admin-Bereich.</p>
</div>
<div class="grid gap-3 md:min-w-[330px]">
<label class="text-sm font-semibold text-slate-600">Season</label>
<Select
v-model="selectedSeasonId"
:options="seasonOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
</div>
</template>
+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 }
+38 -3
View File
@@ -1,7 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import AdminView from './views/AdminView.vue' import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
import AdminDashboardView from './views/admin/AdminDashboardView.vue'
import AdminLayoutView from './views/admin/AdminLayoutView.vue'
import AdminReviewsView from './views/admin/AdminReviewsView.vue'
import AdminRiskView from './views/admin/AdminRiskView.vue'
import AdminSeasonsView from './views/admin/AdminSeasonsView.vue'
import HomeView from './views/HomeView.vue' import HomeView from './views/HomeView.vue'
import NominationsView from './views/NominationsView.vue' import NominationsView from './views/NominationsView.vue'
import VotingView from './views/VotingView.vue' import VotingView from './views/VotingView.vue'
@@ -41,11 +46,41 @@ const router = createRouter({
}, },
{ {
path: '/admin', path: '/admin',
name: 'admin', component: AdminLayoutView,
component: AdminView,
meta: { meta: {
requiresAdmin: true, requiresAdmin: true,
}, },
children: [
{
path: '',
redirect: { name: 'admin-dashboard' },
},
{
path: 'dashboard',
name: 'admin-dashboard',
component: AdminDashboardView,
},
{
path: 'seasons',
name: 'admin-seasons',
component: AdminSeasonsView,
},
{
path: 'candidates',
name: 'admin-candidates',
component: AdminCandidatesView,
},
{
path: 'reviews',
name: 'admin-reviews',
component: AdminReviewsView,
},
{
path: 'risk',
name: 'admin-risk',
component: AdminRiskView,
},
],
}, },
], ],
}) })
+51 -1
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[] = []
@@ -181,6 +208,7 @@ export const useAwardsStore = defineStore('awards', {
admin: fallbackAdmin as AdminDashboardResponse, admin: fallbackAdmin as AdminDashboardResponse,
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[], adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse, adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
adminSelectedSeasonId: fallbackAdminSeasonDetail.id as number | null,
loading: false, loading: false,
apiMode: 'fallback' as 'api' | 'fallback', apiMode: 'fallback' as 'api' | 'fallback',
}), }),
@@ -210,22 +238,39 @@ export const useAwardsStore = defineStore('awards', {
try { try {
this.admin = await api.getAdminDashboard() this.admin = await api.getAdminDashboard()
this.adminSeasons = await api.getAdminSeasons() this.adminSeasons = await api.getAdminSeasons()
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSeasons[0]?.id ?? 1) if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
this.adminSelectedSeasonId = this.adminSeasons[0]?.id ?? null
}
if (this.adminSelectedSeasonId) {
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSelectedSeasonId)
}
this.apiMode = 'api' this.apiMode = 'api'
} catch { } catch {
this.admin = emptyAdmin this.admin = emptyAdmin
this.adminSeasons = emptyAdminSeasons this.adminSeasons = emptyAdminSeasons
this.adminSeasonDetail = emptyAdminSeasonDetail this.adminSeasonDetail = emptyAdminSeasonDetail
this.adminSelectedSeasonId = null
} }
}, },
async loadAdminSeasonDetail(seasonId: number) { async loadAdminSeasonDetail(seasonId: number) {
try { try {
this.adminSelectedSeasonId = seasonId
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId) this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
this.apiMode = 'api' this.apiMode = 'api'
} catch { } catch {
this.adminSeasonDetail = emptyAdminSeasonDetail this.adminSeasonDetail = emptyAdminSeasonDetail
} }
}, },
async initializeAdminWorkspace() {
await this.loadAdmin()
if (this.adminSelectedSeasonId && this.adminSeasonDetail.id !== this.adminSelectedSeasonId) {
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
}
},
setAdminSeason(seasonId: number) {
this.adminSelectedSeasonId = seasonId
},
submitNomination(payload: CreateNominationPayload) { submitNomination(payload: CreateNominationPayload) {
return api.submitNomination(payload) return api.submitNomination(payload)
}, },
@@ -269,5 +314,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 {
-560
View File
@@ -1,560 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Select from 'primevue/select'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedSeasonId = ref<number | null>(null)
const seasonSaving = ref(false)
const categorySaving = ref<number | 'new' | null>(null)
const candidateSaving = ref<number | 'new' | null>(null)
const reviewSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const seasonForm = reactive({
currentPhase: '',
isCurrent: false,
})
const newCategoryForm = reactive({
groupName: '',
name: '',
slug: '',
description: '',
sortOrder: 1,
maxNomineesPerUser: 3,
})
const newCandidateForm = reactive({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
})
const editForms = reactive<Record<number, {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}>>({})
const candidateForms = reactive<Record<number, {
categoryId: number
displayName: string
channelSlug: string
platform: string
}>>({})
const reviewForms = reactive<Record<number, {
displayName: string
channelSlug: string
platform: string
}>>({})
onMounted(async () => {
if (!authStore.isAdmin) return
await store.loadAdmin()
selectedSeasonId.value = store.adminSeasons[0]?.id ?? null
})
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
const seasons = computed(() => store.adminSeasons)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
)
watch(selectedSeasonId, async (seasonId) => {
if (!seasonId) return
await store.loadAdminSeasonDetail(seasonId)
})
watch(
seasonDetail,
(detail) => {
seasonForm.currentPhase = detail.currentPhase
seasonForm.isCurrent = detail.isCurrent
for (const category of detail.categories) {
editForms[category.id] = {
groupName: category.groupName,
name: category.name,
slug: category.slug,
description: category.description,
sortOrder: category.sortOrder,
maxNomineesPerUser: category.maxNomineesPerUser,
}
}
for (const candidate of detail.candidates) {
candidateForms[candidate.id] = {
categoryId: candidate.categoryId,
displayName: candidate.displayName,
channelSlug: candidate.channelSlug,
platform: candidate.platform,
}
}
for (const nomination of detail.pendingNominations) {
reviewForms[nomination.id] = {
displayName: nomination.candidateText,
channelSlug: '',
platform: 'Twitch',
}
}
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
},
{ immediate: true },
)
const seasonOptions = computed(() =>
seasons.value.map((season) => ({
label: `${season.year} · ${season.name}`,
value: season.id,
})),
)
async function saveSeason() {
if (!selectedSeasonId.value) return
seasonSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: seasonForm.currentPhase,
isCurrent: seasonForm.isCurrent,
})
await store.loadAdminSeasonDetail(selectedSeasonId.value)
adminMessage.value = 'Season-Einstellungen gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
} finally {
seasonSaving.value = false
}
}
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
categorySaving.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
adminMessage.value = 'Kategorie gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
} finally {
categorySaving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
categorySaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Neue Kategorie angelegt.'
newCategoryForm.groupName = ''
newCategoryForm.name = ''
newCategoryForm.slug = ''
newCategoryForm.description = ''
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
newCategoryForm.maxNomineesPerUser = 3
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
} finally {
categorySaving.value = null
}
}
async function saveCandidate(candidateId: number) {
if (!selectedSeasonId.value) return
candidateSaving.value = candidateId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
adminMessage.value = 'Kandidat gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
} finally {
candidateSaving.value = null
}
}
async function createCandidate() {
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
candidateSaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
adminMessage.value = 'Kandidat angelegt.'
newCandidateForm.displayName = ''
newCandidateForm.channelSlug = ''
newCandidateForm.platform = 'Twitch'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
} finally {
candidateSaving.value = null
}
}
async function approveNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
} finally {
reviewSaving.value = null
}
}
async function rejectNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
adminMessage.value = 'Nominierung wurde aus der Review Queue entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
} finally {
reviewSaving.value = null
}
}
</script>
<template>
<div class="space-y-10 pb-14">
<Card v-if="!authStore.isAdmin" class="p-8">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin Access</p>
<h1 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Admin Login erforderlich</h1>
<p class="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
Bitte melde dich ueber den Header mit einem Admin-Login an, damit Season-, Category-, Candidate- und Review-Management verfuegbar werden.
</p>
</Card>
<template v-else>
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Betriebswerkzeug fuer Seasons, Kategorien, Kandidaten und Review-Flows</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Das Team pflegt das Jahres-Setup und die operativen Awards-Inhalte direkt aus einer zusammenhaengenden Admin-Oberflaeche.
</p>
</div>
<div class="grid gap-5 lg:grid-cols-4">
<Card
v-for="metric in metrics"
:key="metric.label"
class="p-7"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<Card class="p-7">
<div class="flex flex-col gap-6">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
<p class="mt-2 text-sm text-slate-500">Aktive Season auswaehlen, Phase anpassen und bei Bedarf zum aktuellen Jahr machen.</p>
</div>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Season</label>
<Select
v-model="selectedSeasonId"
:options="seasonOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Phase</label>
<input
v-model="seasonForm.currentPhase"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
/>
</div>
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
Diese Season ist die aktuelle Public Season
</label>
<div class="flex flex-wrap items-center gap-4">
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
</Button>
<span class="text-sm text-slate-500">
{{ seasonDetail.year }} · {{ seasonDetail.name }}
</span>
</div>
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
<DataTable :value="topCategories" class="mt-6" striped-rows>
<Column field="category" header="Kategorie" />
<Column field="votes" header="Votes">
<template #body="{ data }">
{{ Number(data.votes).toLocaleString('de-DE') }}
</template>
</Column>
</DataTable>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.categories.length }} Kategorien
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="category in seasonDetail.categories"
:key="category.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ category.candidateCount }} Kandidaten in dieser Kategorie
</div>
</div>
<textarea
v-model="editForms[category.id].description"
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Beschreibung"
/>
<div class="mt-4 flex justify-end">
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
<div class="mt-6 space-y-4">
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
<div class="grid gap-4 sm:grid-cols-2">
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
</div>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
</Button>
</div>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting/Archiv genutzt werden.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.candidates.length }} Kandidaten
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="candidate in seasonDetail.candidates"
:key="candidate.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<Select
v-model="candidateForms[candidate.id].categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
</div>
<div class="mt-4 flex justify-end">
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
<div class="mt-6 space-y-4">
<Select
v-model="newCandidateForm.categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</Button>
</div>
<h3 class="mt-10 font-[Cormorant_Garamond] text-3xl text-violet-800">Letzte Aktivitaeten</h3>
<div class="mt-4 space-y-4">
<div
v-for="activity in activities"
:key="activity.label"
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
<p class="mt-1 text-sm text-slate-500">{{ activity.age }}</p>
</div>
</div>
</Card>
</div>
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.pendingNominations.length }} offen
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="nomination in seasonDetail.pendingNominations"
:key="nomination.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">{{ nomination.categoryName }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
<p class="mt-2 text-sm text-slate-500">
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
ID {{ nomination.id }}
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<input
v-model="reviewForms[nomination.id].displayName"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Display Name"
/>
<input
v-model="reviewForms[nomination.id].channelSlug"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="@channel"
/>
<input
v-model="reviewForms[nomination.id].platform"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Platform"
/>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
</Button>
</div>
</div>
</div>
</Card>
</template>
</div>
</template>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import Select from 'primevue/select'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const candidateSaving = ref<number | 'new' | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const newCandidateForm = reactive({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
})
const candidateForms = reactive<Record<number, {
categoryId: number
displayName: string
channelSlug: string
platform: string
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
)
watch(
seasonDetail,
(detail) => {
for (const candidate of detail.candidates) {
candidateForms[candidate.id] = {
categoryId: candidate.categoryId,
displayName: candidate.displayName,
channelSlug: candidate.channelSlug,
platform: candidate.platform,
}
}
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
},
{ immediate: true },
)
async function saveCandidate(candidateId: number) {
if (!selectedSeasonId.value) return
candidateSaving.value = candidateId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
adminMessage.value = 'Kandidat gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
} finally {
candidateSaving.value = null
}
}
async function createCandidate() {
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
candidateSaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
adminMessage.value = 'Kandidat angelegt.'
newCandidateForm.displayName = ''
newCandidateForm.channelSlug = ''
newCandidateForm.platform = 'Twitch'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
} finally {
candidateSaving.value = null
}
}
</script>
<template>
<div class="space-y-6">
<AdminSeasonToolbar />
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting und Archiv genutzt werden.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.candidates.length }} Kandidaten
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="candidate in seasonDetail.candidates"
:key="candidate.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<Select
v-model="candidateForms[candidate.id].categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
</div>
<div class="mt-4 flex justify-end">
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
<div class="mt-6 space-y-4">
<Select
v-model="newCandidateForm.categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</Button>
</div>
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p>
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
</Card>
</div>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
</script>
<template>
<div class="space-y-6">
<div class="grid gap-5 lg:grid-cols-4">
<Card
v-for="metric in metrics"
:key="metric.label"
class="p-7"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
<DataTable :value="topCategories" class="mt-6" striped-rows>
<Column field="category" header="Kategorie" />
<Column field="votes" header="Votes">
<template #body="{ data }">
{{ Number(data.votes).toLocaleString('de-DE') }}
</template>
</Column>
</DataTable>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Letzte Aktivitaeten</h2>
<div class="mt-6 space-y-4">
<div
v-for="activity in activities"
:key="activity.label"
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
<p class="mt-1 text-sm text-slate-500">{{ activity.age }}</p>
</div>
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine aktuellen Audit-Aktivitaeten vorhanden.
</p>
</div>
</Card>
</div>
</div>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
const route = useRoute()
const store = useAwardsStore()
const authStore = useAuthStore()
const navItems = [
{ label: 'Dashboard', to: '/admin/dashboard', description: 'KPIs, Trends, letzte Aktivitaet' },
{ label: 'Seasons', to: '/admin/seasons', description: 'Season-Status, Kategorien, Limits' },
{ label: 'Candidates', to: '/admin/candidates', description: 'Kandidatenbasis pro Season pflegen' },
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Nominierungen bearbeiten' },
{ label: 'Risk & Audit', to: '/admin/risk', description: 'Flags pruefen, Aktionen nachvollziehen' },
]
const currentSeason = computed(() => store.adminSeasonDetail)
onMounted(async () => {
if (!authStore.isAdmin) return
await store.initializeAdminWorkspace()
})
</script>
<template>
<div class="space-y-8 pb-14">
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">
Betriebswerkzeug fuer Awards, Moderation und Risiko-Sichtung
</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Der Admin-Bereich ist in klar getrennte Arbeitszonen aufgeteilt, damit Season-Pflege, Review und Monitoring nicht mehr auf einer einzigen Seite kollidieren.
</p>
</div>
<div class="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
<Card class="h-fit p-4">
<nav class="space-y-2">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="block rounded-[24px] border px-4 py-4 transition"
:class="route.path === item.to ? 'border-violet-200 bg-violet-100/80 text-violet-900' : 'border-transparent bg-white/70 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
>
<p class="text-sm font-semibold uppercase tracking-[0.18em]">{{ item.label }}</p>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ item.description }}</p>
</RouterLink>
</nav>
<div class="mt-6 rounded-[24px] border border-violet-100 bg-violet-50/60 px-4 py-4">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktive Season</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-3xl text-violet-800">
{{ currentSeason.year ? `${currentSeason.year}` : 'Keine Season' }}
</h2>
<p class="mt-2 text-sm text-slate-600">{{ currentSeason.name || 'Bitte Season auswaehlen.' }}</p>
<p class="mt-3 text-xs uppercase tracking-[0.2em] text-slate-500">{{ currentSeason.currentPhase || 'Kein Status' }}</p>
</div>
</Card>
<RouterView />
</div>
</div>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const reviewSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const reviewForms = reactive<Record<number, {
displayName: string
channelSlug: string
platform: string
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
watch(
seasonDetail,
(detail) => {
for (const nomination of detail.pendingNominations) {
reviewForms[nomination.id] = {
displayName: nomination.candidateText,
channelSlug: '',
platform: 'Twitch',
}
}
},
{ immediate: true },
)
async function approveNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
} finally {
reviewSaving.value = null
}
}
async function rejectNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
adminMessage.value = 'Nominierung wurde aus der Review Queue entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
} finally {
reviewSaving.value = null
}
}
</script>
<template>
<div class="space-y-6">
<AdminSeasonToolbar />
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.pendingNominations.length }} offen
</span>
</div>
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p>
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
<div class="mt-6 space-y-4">
<div
v-for="nomination in seasonDetail.pendingNominations"
:key="nomination.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">{{ nomination.categoryName }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
<p class="mt-2 text-sm text-slate-500">
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
ID {{ nomination.id }}
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<input
v-model="reviewForms[nomination.id].displayName"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Display Name"
/>
<input
v-model="reviewForms[nomination.id].channelSlug"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="@channel"
/>
<input
v-model="reviewForms[nomination.id].platform"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Platform"
/>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
</Button>
</div>
</div>
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Review-Faelle in der aktuell gewaehlten Season.
</p>
</div>
</Card>
</div>
</template>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const riskSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const riskFlags = computed(() => store.admin.riskFlags)
const auditEntries = computed(() => store.admin.auditEntries)
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>
<template>
<div class="space-y-6">
<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-, Nominierungs- 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>
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p>
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
<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>
<p v-if="riskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Risk Flags vorhanden.
</p>
</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>
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine Audit-Eintraege vorhanden.
</p>
</div>
</Card>
</div>
</div>
</template>
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasonSaving = ref(false)
const categorySaving = ref<number | 'new' | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const seasonForm = reactive({
currentPhase: '',
isCurrent: false,
})
const newCategoryForm = reactive({
groupName: '',
name: '',
slug: '',
description: '',
sortOrder: 1,
maxNomineesPerUser: 3,
})
const editForms = reactive<Record<number, {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
watch(
seasonDetail,
(detail) => {
seasonForm.currentPhase = detail.currentPhase
seasonForm.isCurrent = detail.isCurrent
for (const category of detail.categories) {
editForms[category.id] = {
groupName: category.groupName,
name: category.name,
slug: category.slug,
description: category.description,
sortOrder: category.sortOrder,
maxNomineesPerUser: category.maxNomineesPerUser,
}
}
newCategoryForm.sortOrder = detail.categories.length + 1
},
{ immediate: true },
)
async function saveSeason() {
if (!selectedSeasonId.value) return
seasonSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: seasonForm.currentPhase,
isCurrent: seasonForm.isCurrent,
})
adminMessage.value = 'Season-Einstellungen gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
} finally {
seasonSaving.value = false
}
}
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
categorySaving.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
adminMessage.value = 'Kategorie gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
} finally {
categorySaving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
categorySaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Neue Kategorie angelegt.'
newCategoryForm.groupName = ''
newCategoryForm.name = ''
newCategoryForm.slug = ''
newCategoryForm.description = ''
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
newCategoryForm.maxNomineesPerUser = 3
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
} finally {
categorySaving.value = null
}
}
</script>
<template>
<div class="space-y-6">
<AdminSeasonToolbar />
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card class="p-7">
<div class="flex flex-col gap-6">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
<p class="mt-2 text-sm text-slate-500">Phase, Current-Status und Basiskontext fuer die aktive Awards-Season.</p>
</div>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Phase</label>
<input
v-model="seasonForm.currentPhase"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
/>
</div>
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
Diese Season ist die aktuelle Public Season
</label>
<div class="flex flex-wrap items-center gap-4">
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
</Button>
<span class="text-sm text-slate-500">
{{ seasonDetail.year }} · {{ seasonDetail.name }}
</span>
</div>
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
<div class="mt-6 space-y-4">
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
<div class="grid gap-4 sm:grid-cols-2">
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
</div>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
</Button>
</div>
</Card>
</div>
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.categories.length }} Kategorien
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="category in seasonDetail.categories"
:key="category.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ category.candidateCount }} Kandidaten in dieser Kategorie
</div>
</div>
<textarea
v-model="editForms[category.id].description"
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Beschreibung"
/>
<div class="mt-4 flex justify-end">
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
</Button>
</div>
</div>
</div>
</Card>
</div>
</template>