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"] ?? builder.Configuration.GetConnectionString("Postgres"); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddCors(options => { options.AddPolicy("frontend", policy => { policy .WithOrigins( "http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:4173", "http://127.0.0.1:4173") .AllowAnyHeader() .AllowAnyMethod(); }); }); builder.Services.AddDbContext(options => { options.UseNpgsql(connectionString); }); var app = builder.Build(); static string? ReadBearerToken(HttpContext context) { var header = context.Request.Headers.Authorization.ToString(); return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : null; } static async Task ResolveSessionAsync(HttpContext context, AwardsDbContext db) { var token = ReadBearerToken(context); if (string.IsNullOrWhiteSpace(token)) { return null; } var session = await db.UserSessions.FirstOrDefaultAsync(item => item.SessionToken == token && item.IsActive); if (session is not null) { session.LastSeenAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); } 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(); app.UseSwaggerUI(); } app.UseCors("frontend"); app.UseHttpsRedirection(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); if (app.Environment.IsDevelopment()) { try { db.Database.Migrate(); } catch { // In local environments without PostgreSQL yet, the API should still boot // so frontend work and migration generation can continue independently. } } try { await SessionBootstrapper.EnsureAsync(db); await OperationalTablesBootstrapper.EnsureAsync(db); } catch { // If the operational table bootstrap fails, the rest of the API can still start. } } 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(), SessionToken = Guid.NewGuid().ToString("N"), 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(); return Results.Ok(new AuthSessionDto( session.SessionToken, session.TwitchUserId, session.DisplayName, session.Role)); }) .WithName("DevLogin") .WithOpenApi(); app.MapGet("/api/auth/session", async (HttpContext context, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session is null) { return Results.Unauthorized(); } return Results.Ok(new AuthSessionDto( session.SessionToken, session.TwitchUserId, session.DisplayName, session.Role)); }) .WithName("GetSession") .WithOpenApi(); app.MapPost("/api/auth/logout", async (HttpContext context, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session is null) { return Results.Ok(new { loggedOut = true }); } session.IsActive = false; await db.SaveChangesAsync(); return Results.Ok(new { loggedOut = true }); }) .WithName("Logout") .WithOpenApi(); app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })) .WithName("GetHealth") .WithOpenApi(); app.MapGet("/api/health/database", async (AwardsDbContext db) => { try { var canConnect = await db.Database.CanConnectAsync(); var pendingMigrations = canConnect ? await db.Database.GetPendingMigrationsAsync() : Array.Empty(); return Results.Ok(new { provider = "postgres", canConnect, pendingMigrations, configuredConnection = new { source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings", }, }); } catch (Exception exception) { return Results.Ok(new { provider = "postgres", canConnect = false, pendingMigrations = Array.Empty(), configuredConnection = new { source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings", }, error = exception.Message, }); } }) .WithName("GetDatabaseHealth") .WithOpenApi(); app.MapGet("/api/public/overview", async (AwardsDbContext db) => { var season = await db.Seasons .AsNoTracking() .Include(item => item.Categories.OrderBy(category => category.SortOrder)) .Include(item => item.Results) .ThenInclude(result => result.Candidate) .FirstOrDefaultAsync(item => item.IsCurrent); if (season is null) { return Results.NotFound(); } var response = new OverviewResponse( season.Id, season.Year, season.Name, season.ShowDate, season.CurrentPhase, season.IsCommunityOnly, "Twitch", new[] { new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, "done"), new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, "active"), new TimelineItem("review", "Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, "upcoming"), new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, "upcoming"), }, season.Categories .Take(6) .Select(category => new FeaturedCategoryDto( category.Id, category.GroupName, category.Name, category.Description, category.MaxNomineesPerUser)) .ToArray(), season.Results .OrderByDescending(result => result.SeasonId) .Take(4) .Select(result => new WinnerPreviewDto( season.Year, result.CategoryName, result.Candidate.DisplayName, result.Candidate.ChannelSlug)) .ToArray(), new[] { new FaqItemDto("Wer kann nominieren und voten?", "Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt."), new FaqItemDto("Wie werden Gewinner bestimmt?", "Aktuell rein community-basiert. Eine Mischlogik mit Jury oder Panel kann spaeter eingefuehrt werden."), new FaqItemDto("Wer verwaltet Kategorien und Unterkategorien?", "Das Team pflegt diese pro Jahr im Admin-Bereich."), }); return Results.Ok(response); }) .WithName("GetOverview") .WithOpenApi(); app.MapGet("/api/public/seasons/{year:int}/categories", async (int year, AwardsDbContext db) => { var season = await db.Seasons .AsNoTracking() .Include(item => item.Categories.OrderBy(category => category.SortOrder)) .ThenInclude(category => category.Candidates.OrderBy(candidate => candidate.DisplayName)) .FirstOrDefaultAsync(item => item.Year == year); if (season is null) { return Results.NotFound(); } return Results.Ok(new SeasonCategoriesResponse( season.Id, season.Year, season.Categories.Select(category => new PublicCategoryDetailDto( category.Id, category.Name, category.GroupName, category.Description, category.MaxNomineesPerUser, category.Candidates.Select(candidate => new CandidateSummaryDto( candidate.Id, candidate.DisplayName, candidate.ChannelSlug, candidate.Platform)) .ToArray())) .ToArray())); }) .WithName("GetSeasonCategories") .WithOpenApi(); app.MapGet("/api/public/seasons/{year:int}/winners", async (int year, AwardsDbContext db) => { var items = await db.Results .AsNoTracking() .Include(result => result.Candidate) .Where(result => result.Season.Year == year) .OrderBy(result => result.CategoryName) .Select(result => new WinnerArchiveItemDto( result.CategoryName, result.Candidate.DisplayName, result.Candidate.ChannelSlug)) .ToArrayAsync(); return Results.Ok(new WinnerArchiveResponse(year, items)); }) .WithName("GetWinnerArchive") .WithOpenApi(); app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominationRequest request, AwardsDbContext db) => { if (request.Nominees.Length is 0 or > 3) { return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." }); } var distinctNominees = request.Nominees .Select(item => item.Trim()) .Where(item => !string.IsNullOrWhiteSpace(item)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); if (distinctNominees.Length != request.Nominees.Length) { return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." }); } var category = await db.Categories .Include(item => item.Season) .FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year); if (category is null) { return Results.BadRequest(new { message = "The selected category does not exist for this season." }); } var session = await ResolveSessionAsync(context, db); var submitterId = session?.TwitchUserId ?? request.TwitchUserId; if (string.IsNullOrWhiteSpace(submitterId)) { 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, CategoryId = category.Id, SubmittedByTwitchId = submitterId, CandidateText = name, CreatedAt = DateTimeOffset.UtcNow, }); 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, replacedPrevious = previousNominations.Length > 0 }); }) .WithName("CreateNomination") .WithOpenApi(); app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest request, AwardsDbContext db) => { if (request.Entries.Length == 0) { return Results.BadRequest(new { message = "At least one vote entry is required." }); } var distinctCategoryCount = request.Entries .Select(item => item.CategoryId) .Distinct() .Count(); if (distinctCategoryCount != request.Entries.Length) { return Results.BadRequest(new { message = "Only one vote entry per category is allowed." }); } var session = await ResolveSessionAsync(context, db); var submitterId = session?.TwitchUserId ?? request.TwitchUserId; if (string.IsNullOrWhiteSpace(submitterId)) { return Results.BadRequest(new { message = "A logged in user is required to submit votes." }); } 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(); 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, updated = isResubmission }); }) .WithName("CreateVote") .WithOpenApi(); app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent); if (currentSeason is null) { return Results.NotFound(); } var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id); var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id); var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id); var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null); var topCategoryNames = await db.VoteEntries .AsNoTracking() .Where(item => item.Ballot.SeasonId == currentSeason.Id) .Select(item => item.Category.Name) .ToListAsync(); var topCategories = topCategoryNames .GroupBy(name => name) .Select(group => new AdminTopCategoryDto(group.Key, group.Count())) .OrderByDescending(item => item.Votes) .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[] { new AdminMetricDto("Nominierungen", nominationCount, "+12.4% vs. gestern"), new AdminMetricDto("Votes", voteCount, "+8.7% vs. gestern"), new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"), new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"), }, activityItems, topCategories, riskFlags, auditEntries); return Results.Ok(response); }) .WithName("GetAdminDashboard") .WithOpenApi(); app.MapGet("/api/admin/seasons", async (HttpContext context, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var seasons = await db.Seasons .AsNoTracking() .OrderByDescending(item => item.Year) .Select(item => new AdminSeasonListItemDto( item.Id, item.Year, item.Name, item.CurrentPhase, item.IsCurrent, item.Categories.Count)) .ToArrayAsync(); return Results.Ok(seasons); }) .WithName("GetAdminSeasons") .WithOpenApi(); app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var season = await db.Seasons .AsNoTracking() .FirstOrDefaultAsync(item => item.Id == seasonId); if (season is null) { return Results.NotFound(); } var candidates = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == seasonId) .OrderBy(item => item.DisplayName) .Select(item => new AdminCandidateItemDto( item.Id, item.CategoryId, item.DisplayName, item.ChannelSlug, item.Platform)) .ToArrayAsync(); var candidateCounts = candidates .GroupBy(item => item.CategoryId) .ToDictionary(group => group.Key, group => group.Count()); var categoryRows = await db.Categories .AsNoTracking() .Where(item => item.SeasonId == seasonId) .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .Select(category => new { category.Id, category.GroupName, category.Name, category.Slug, category.Description, category.SortOrder, category.MaxNomineesPerUser, }) .ToArrayAsync(); var categories = categoryRows .Select(category => new AdminCategoryItemDto( category.Id, category.GroupName, category.Name, category.Slug, category.Description, category.SortOrder, category.MaxNomineesPerUser, candidateCounts.TryGetValue(category.Id, out var count) ? count : 0)) .ToArray(); var pendingNominations = await db.Nominations .AsNoTracking() .Where(item => item.SeasonId == seasonId && item.CandidateText != null) .OrderByDescending(item => item.CreatedAt) .Take(20) .Select(item => new AdminNominationReviewItemDto( item.Id, item.CategoryId, item.Category.Name, item.SubmittedByTwitchId, item.CandidateText!, item.CreatedAt)) .ToArrayAsync(); return Results.Ok(new AdminSeasonDetailResponse( season.Id, season.Year, season.Name, season.CurrentPhase, season.IsCurrent, categories, candidates, pendingNominations)); }) .WithName("GetAdminSeasonDetail") .WithOpenApi(); app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, UpdateSeasonRequest request, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId); if (season is null) { return Results.NotFound(); } season.CurrentPhase = request.CurrentPhase.Trim(); if (request.IsCurrent && !season.IsCurrent) { var activeSeasons = await db.Seasons.Where(item => item.IsCurrent && item.Id != seasonId).ToListAsync(); foreach (var activeSeason in activeSeasons) { activeSeason.IsCurrent = false; } } 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 }); }) .WithName("UpdateAdminSeason") .WithOpenApi(); app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext context, int seasonId, UpsertCategoryRequest request, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId); if (season is null) { return Results.NotFound(); } var category = new Category { SeasonId = seasonId, GroupName = request.GroupName.Trim(), Name = request.Name.Trim(), Slug = request.Slug.Trim(), Description = request.Description.Trim(), SortOrder = request.SortOrder, MaxNomineesPerUser = request.MaxNomineesPerUser, }; 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 }); }) .WithName("CreateAdminCategory") .WithOpenApi(); app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, UpsertCategoryRequest request, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId); if (category is null) { return Results.NotFound(); } category.GroupName = request.GroupName.Trim(); category.Name = request.Name.Trim(); category.Slug = request.Slug.Trim(); category.Description = request.Description.Trim(); 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 }); }) .WithName("UpdateAdminCategory") .WithOpenApi(); app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext context, int seasonId, UpsertCandidateRequest request, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId); if (category is null) { return Results.BadRequest(new { message = "The selected category does not exist in this season." }); } var candidate = new Candidate { SeasonId = seasonId, CategoryId = request.CategoryId, DisplayName = request.DisplayName.Trim(), ChannelSlug = request.ChannelSlug.Trim(), Platform = request.Platform.Trim(), }; 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 }); }) .WithName("CreateAdminCandidate") .WithOpenApi(); app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, UpsertCandidateRequest request, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId); if (candidate is null) { return Results.NotFound(); } candidate.CategoryId = request.CategoryId; candidate.DisplayName = request.DisplayName.Trim(); 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 }); }) .WithName("UpdateAdminCandidate") .WithOpenApi(); app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var nomination = await db.Nominations .Include(item => item.Category) .FirstOrDefaultAsync(item => item.Id == nominationId); if (nomination is null) { return Results.NotFound(); } var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? nomination.CandidateText : request.DisplayName.Trim(); if (string.IsNullOrWhiteSpace(rawDisplayName)) { return Results.BadRequest(new { message = "A display name is required to approve the nomination." }); } var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty; var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim(); var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item => item.SeasonId == nomination.SeasonId && item.CategoryId == nomination.CategoryId && item.DisplayName.ToLower() == rawDisplayName.ToLower()); var candidate = existingCandidate; if (candidate is null) { candidate = new Candidate { SeasonId = nomination.SeasonId, CategoryId = nomination.CategoryId, DisplayName = rawDisplayName, ChannelSlug = channelSlug, Platform = platform, }; db.Candidates.Add(candidate); await db.SaveChangesAsync(); } else { if (!string.IsNullOrWhiteSpace(channelSlug)) { candidate.ChannelSlug = channelSlug; } if (!string.IsNullOrWhiteSpace(platform)) { candidate.Platform = platform; } } 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 }); }) .WithName("ApproveAdminNomination") .WithOpenApi(); app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpContext context, int nominationId, AwardsDbContext db) => { var session = await ResolveSessionAsync(context, db); if (session?.Role != "admin") { return Results.Unauthorized(); } var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId); if (nomination is null) { return Results.NotFound(); } 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 }); }) .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();