1160 lines
36 KiB
C#
1160 lines
36 KiB
C#
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<AwardsDbContext>(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<UserSession?> 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<AwardsDbContext>();
|
|
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<string>();
|
|
|
|
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<string>(),
|
|
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();
|