Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminModerationEndpoints
|
||||
{
|
||||
private static async Task<IResult> DeleteClip(
|
||||
HttpContext context,
|
||||
int clipId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
|
||||
if (clip is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.ClipSubmissions.Remove(clip);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"clip.delete",
|
||||
"clip",
|
||||
clip.Id.ToString(),
|
||||
$"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.",
|
||||
new { clip.Platform },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, clipId });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateClipStatus(
|
||||
HttpContext context,
|
||||
int clipId,
|
||||
UpdateClipStatusRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
|
||||
if (clip is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedStatus = string.IsNullOrWhiteSpace(request.Status)
|
||||
? "pending"
|
||||
: request.Status.Trim().ToLowerInvariant();
|
||||
|
||||
if (normalizedStatus is not ("pending" or "approved" or "rejected"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Clip status must be pending, approved or rejected." });
|
||||
}
|
||||
|
||||
clip.Status = normalizedStatus;
|
||||
clip.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
if (normalizedStatus == "pending")
|
||||
{
|
||||
clip.ReviewedAt = null;
|
||||
clip.ReviewedByTwitchId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
clip.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
clip.ReviewedByTwitchId = session.TwitchUserId;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"clip.status.update",
|
||||
"clip",
|
||||
clip.Id.ToString(),
|
||||
$"Clip-Einreichung {clip.Id} wurde auf {clip.Status} gesetzt.",
|
||||
new { clip.Status, clip.ReviewNote },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, clipId = clip.Id, status = clip.Status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static class AdminDashboardEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/dashboard", GetDashboard).WithName("GetAdminDashboard").WithOpenApi();
|
||||
group.MapGet("/audit-entries", GetAuditEntries).WithName("GetAdminAuditEntries").WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDashboard(AwardsDbContext db)
|
||||
{
|
||||
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 riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
|
||||
|
||||
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(grouping => new AdminTopCategoryDto(grouping.Key, grouping.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)
|
||||
.ToArrayAsync();
|
||||
var riskFlagDtos = riskFlags.Select(AdminRiskFlagMappings.ToDto).ToArray();
|
||||
|
||||
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,
|
||||
item.MetadataJson,
|
||||
item.CreatedFromIp,
|
||||
item.UserAgent))
|
||||
.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();
|
||||
|
||||
return Results.Ok(new AdminDashboardResponse(
|
||||
new[]
|
||||
{
|
||||
new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Reviews offen", reviewCount, "Freitext-Nominierungen mit Review-Bedarf"),
|
||||
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
|
||||
},
|
||||
activityItems,
|
||||
topCategories,
|
||||
riskFlagDtos,
|
||||
auditEntries));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAuditEntries(
|
||||
int? limit,
|
||||
string? query,
|
||||
string? admin,
|
||||
string? action,
|
||||
string? entityType,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
string? cursor,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500);
|
||||
var search = query?.Trim();
|
||||
var auditQuery = db.AdminAuditEntries.AsNoTracking();
|
||||
|
||||
if (!TryDecodeAuditCursor(cursor, out var decodedCursor))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Invalid audit cursor." });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var pattern = $"%{search}%";
|
||||
auditQuery = auditQuery.Where(item =>
|
||||
EF.Functions.ILike(item.AdminTwitchUserId, pattern) ||
|
||||
EF.Functions.ILike(item.ActionType, pattern) ||
|
||||
EF.Functions.ILike(item.EntityType, pattern) ||
|
||||
EF.Functions.ILike(item.EntityId, pattern) ||
|
||||
EF.Functions.ILike(item.Summary, pattern) ||
|
||||
EF.Functions.ILike(item.MetadataJson, pattern) ||
|
||||
EF.Functions.ILike(item.CreatedFromIp, pattern) ||
|
||||
EF.Functions.ILike(item.UserAgent, pattern));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(admin))
|
||||
{
|
||||
var normalizedAdmin = admin.Trim();
|
||||
auditQuery = auditQuery.Where(item => item.AdminTwitchUserId == normalizedAdmin);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
var normalizedAction = action.Trim();
|
||||
auditQuery = auditQuery.Where(item => item.ActionType == normalizedAction);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityType))
|
||||
{
|
||||
var normalizedEntityType = entityType.Trim();
|
||||
auditQuery = auditQuery.Where(item => item.EntityType == normalizedEntityType);
|
||||
}
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
auditQuery = auditQuery.Where(item => item.CreatedAt >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
auditQuery = auditQuery.Where(item => item.CreatedAt <= to.Value);
|
||||
}
|
||||
|
||||
var totalCount = await auditQuery.CountAsync();
|
||||
|
||||
if (decodedCursor is not null)
|
||||
{
|
||||
auditQuery = auditQuery.Where(item =>
|
||||
item.CreatedAt < decodedCursor.CreatedAt ||
|
||||
(item.CreatedAt == decodedCursor.CreatedAt && item.Id < decodedCursor.Id));
|
||||
}
|
||||
|
||||
var page = await auditQuery
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Take(normalizedLimit + 1)
|
||||
.Select(item => new AdminAuditEntryDto(
|
||||
item.Id,
|
||||
item.AdminTwitchUserId,
|
||||
item.ActionType,
|
||||
item.EntityType,
|
||||
item.EntityId,
|
||||
item.Summary,
|
||||
item.CreatedAt,
|
||||
item.MetadataJson,
|
||||
item.CreatedFromIp,
|
||||
item.UserAgent))
|
||||
.ToArrayAsync();
|
||||
|
||||
var hasMore = page.Length > normalizedLimit;
|
||||
var entries = page.Take(normalizedLimit).ToArray();
|
||||
var nextCursor = hasMore && entries.Length > 0
|
||||
? EncodeAuditCursor(entries[^1])
|
||||
: null;
|
||||
|
||||
return Results.Ok(new AdminAuditEntriesResponse(
|
||||
entries,
|
||||
totalCount,
|
||||
entries.Length,
|
||||
nextCursor,
|
||||
normalizedLimit));
|
||||
}
|
||||
|
||||
private static string EncodeAuditCursor(AdminAuditEntryDto entry) =>
|
||||
$"{entry.CreatedAt.UtcTicks}:{entry.Id}";
|
||||
|
||||
private static bool TryDecodeAuditCursor(string? cursor, out AuditCursor? decodedCursor)
|
||||
{
|
||||
decodedCursor = null;
|
||||
if (string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts = cursor.Split(':', 2);
|
||||
if (parts.Length != 2 ||
|
||||
!long.TryParse(parts[0], out var ticks) ||
|
||||
!int.TryParse(parts[1], out var id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
decodedCursor = new AuditCursor(new DateTimeOffset(ticks, TimeSpan.Zero), id);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AuditCursor(DateTimeOffset CreatedAt, int Id);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Backend.Domain;
|
||||
using Backend.Security;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
internal static class AdminEndpointConventions
|
||||
{
|
||||
public static UserSession CurrentSession(HttpContext context) =>
|
||||
context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context.");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Backend.Security;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static class AdminEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/admin")
|
||||
.AddEndpointFilter<AdminSessionFilter>();
|
||||
|
||||
group.MapAdminSiteSettingsEndpoints();
|
||||
|
||||
var managerGroup = group.MapGroup(string.Empty)
|
||||
.AddEndpointFilter(RequireAdminWorkspaceRole);
|
||||
|
||||
managerGroup.MapAdminDashboardEndpoints();
|
||||
managerGroup.MapAdminSeasonManagementEndpoints();
|
||||
managerGroup.MapAdminModerationEndpoints();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async ValueTask<object?> RequireAdminWorkspaceRole(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context.HttpContext);
|
||||
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "This admin area requires an admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminModerationEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapDelete("/clips/{clipId:int}", DeleteClip).WithName("DeleteAdminClip").WithOpenApi();
|
||||
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus).WithName("UpdateAdminClipStatus").WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination).WithName("ApproveAdminNomination").WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination).WithName("RejectAdminNomination").WithOpenApi();
|
||||
group.MapGet("/risk-flags", GetRiskFlags).WithName("GetAdminRiskFlags").WithOpenApi();
|
||||
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag).WithName("ResolveRiskFlag").WithOpenApi();
|
||||
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags).WithName("BulkResolveRiskFlags").WithOpenApi();
|
||||
group.MapGet("/risk-rules", GetRiskRules).WithName("GetAdminRiskRules").WithOpenApi();
|
||||
group.MapPut("/risk-rules", UpdateRiskRules).WithName("UpdateAdminRiskRules").WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminModerationEndpoints
|
||||
{
|
||||
private static async Task<IResult> ApproveNomination(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
ApproveNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
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(context.RequestAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||
{
|
||||
candidate.ChannelSlug = channelSlug;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
candidate.Platform = platform;
|
||||
}
|
||||
}
|
||||
|
||||
nomination.CandidateId = candidate.Id;
|
||||
nomination.Status = "approved";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.approve",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectNomination(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
RejectNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
nomination.CandidateId = null;
|
||||
nomination.Status = "rejected";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.reject",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.",
|
||||
new { nomination.ReviewNote },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Security;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminModerationEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetRiskFlags(
|
||||
int? limit,
|
||||
int? offset,
|
||||
string? status,
|
||||
string? severity,
|
||||
string? query,
|
||||
bool? reviewedOnly,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var normalizedLimit = Math.Clamp(limit ?? 25, 1, 100);
|
||||
var normalizedOffset = Math.Max(offset ?? 0, 0);
|
||||
var normalizedStatus = NormalizeRiskFlagStatus(status, allowAll: true);
|
||||
if (normalizedStatus is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Risk flag status must be open, resolved, dismissed or all." });
|
||||
}
|
||||
|
||||
var riskQuery = db.RiskFlags.AsNoTracking();
|
||||
|
||||
if (reviewedOnly == true)
|
||||
{
|
||||
riskQuery = riskQuery.Where(item => item.Status != "open");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var normalizedSeverity = severity.Trim().ToLowerInvariant();
|
||||
if (normalizedSeverity is not ("low" or "medium" or "high"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Risk flag severity must be low, medium, high or all." });
|
||||
}
|
||||
|
||||
riskQuery = riskQuery.Where(item => item.Severity == normalizedSeverity);
|
||||
}
|
||||
|
||||
if (normalizedStatus != "all")
|
||||
{
|
||||
riskQuery = riskQuery.Where(item => item.Status == normalizedStatus);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
var pattern = $"%{query.Trim()}%";
|
||||
riskQuery = riskQuery.Where(item =>
|
||||
EF.Functions.ILike(item.Source, pattern) ||
|
||||
EF.Functions.ILike(item.Type, pattern) ||
|
||||
EF.Functions.ILike(item.Severity, pattern) ||
|
||||
EF.Functions.ILike(item.Status, pattern) ||
|
||||
EF.Functions.ILike(item.Summary, pattern) ||
|
||||
(item.TwitchUserId != null && EF.Functions.ILike(item.TwitchUserId, pattern)) ||
|
||||
EF.Functions.ILike(item.CreatedFromIp, pattern) ||
|
||||
(item.ReviewNote != null && EF.Functions.ILike(item.ReviewNote, pattern)) ||
|
||||
EF.Functions.ILike(item.MetadataJson, pattern));
|
||||
}
|
||||
|
||||
var totalCount = await riskQuery.CountAsync();
|
||||
var severityCounts = await riskQuery
|
||||
.GroupBy(item => item.Severity)
|
||||
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
|
||||
.ToArrayAsync();
|
||||
var statusCounts = await riskQuery
|
||||
.GroupBy(item => item.Status)
|
||||
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
|
||||
.ToArrayAsync();
|
||||
|
||||
var flags = await riskQuery
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArrayAsync();
|
||||
|
||||
var items = flags.Select(AdminRiskFlagMappings.ToDto).ToArray();
|
||||
|
||||
return Results.Ok(new AdminRiskFlagsResponse(
|
||||
items,
|
||||
totalCount,
|
||||
items.Length,
|
||||
normalizedOffset,
|
||||
normalizedLimit,
|
||||
normalizedOffset + items.Length < totalCount,
|
||||
severityCounts,
|
||||
statusCounts));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResolveRiskFlag(
|
||||
HttpContext context,
|
||||
int riskFlagId,
|
||||
ResolveRiskFlagRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
|
||||
if (riskFlag is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
|
||||
if (normalizedStatus is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Risk flag status must be open, resolved or dismissed." });
|
||||
}
|
||||
|
||||
var previousStatus = riskFlag.Status;
|
||||
var previousReviewNote = riskFlag.ReviewNote;
|
||||
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
|
||||
|
||||
riskFlag.Status = normalizedStatus;
|
||||
if (normalizedStatus == "open")
|
||||
{
|
||||
riskFlag.ReviewedAt = null;
|
||||
riskFlag.ReviewedByTwitchId = null;
|
||||
riskFlag.ReviewNote = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
|
||||
riskFlag.ReviewNote = normalizedReviewNote;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"risk.resolve",
|
||||
"risk-flag",
|
||||
riskFlag.Id.ToString(),
|
||||
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
|
||||
new
|
||||
{
|
||||
riskFlag.Type,
|
||||
riskFlag.Source,
|
||||
changes = BuildRiskResolutionChanges(previousStatus, riskFlag.Status, previousReviewNote, riskFlag.ReviewNote),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
|
||||
}
|
||||
|
||||
private static async Task<IResult> BulkResolveRiskFlags(
|
||||
HttpContext context,
|
||||
BulkResolveRiskFlagsRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var normalizedIds = request.RiskFlagIds
|
||||
.Distinct()
|
||||
.Take(100)
|
||||
.ToArray();
|
||||
if (normalizedIds.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte waehle mindestens einen Risikohinweis aus." });
|
||||
}
|
||||
|
||||
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
|
||||
if (normalizedStatus is null || normalizedStatus == "open")
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bulk-Verarbeitung unterstuetzt erledigt oder verworfen." });
|
||||
}
|
||||
|
||||
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
|
||||
if (string.IsNullOrWhiteSpace(normalizedReviewNote))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht eine Review-Notiz." });
|
||||
}
|
||||
|
||||
var riskFlags = await db.RiskFlags
|
||||
.Where(item => normalizedIds.Contains(item.Id))
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (riskFlags.Length != normalizedIds.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Mindestens ein Risikohinweis wurde nicht gefunden." });
|
||||
}
|
||||
|
||||
if (riskFlags.Any(item => item.Severity != "low"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer Low-Severity-Hinweise erlaubt." });
|
||||
}
|
||||
|
||||
if (riskFlags.Any(item => item.Status != "open"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Hinweise erlaubt." });
|
||||
}
|
||||
|
||||
var reviewedAt = DateTimeOffset.UtcNow;
|
||||
foreach (var riskFlag in riskFlags)
|
||||
{
|
||||
riskFlag.Status = normalizedStatus;
|
||||
riskFlag.ReviewNote = normalizedReviewNote;
|
||||
riskFlag.ReviewedAt = reviewedAt;
|
||||
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"risk.bulk-resolve",
|
||||
"risk-flag",
|
||||
string.Join(",", normalizedIds),
|
||||
$"{normalizedIds.Length} Low-Risk Flags wurden als {normalizedStatus} markiert.",
|
||||
new
|
||||
{
|
||||
status = normalizedStatus,
|
||||
count = normalizedIds.Length,
|
||||
riskFlagIds = normalizedIds,
|
||||
changes = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
field = "status",
|
||||
label = "Status",
|
||||
from = "open",
|
||||
to = normalizedStatus,
|
||||
sensitive = false,
|
||||
},
|
||||
new
|
||||
{
|
||||
field = "reviewNote",
|
||||
label = "Review-Notiz",
|
||||
from = "keine Notiz",
|
||||
to = "Notiz vorhanden",
|
||||
sensitive = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, count = normalizedIds.Length, status = normalizedStatus });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRiskRules(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new AdminRiskRulesResponse(RiskRuleSettings.Read(settings).Select(ToRiskRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRiskRules(
|
||||
HttpContext context,
|
||||
UpdateRiskRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Risk-Regeln koennen nur Admins oder Owner aendern." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = RiskRuleSettings.Read(settings);
|
||||
var mergedRules = RiskRuleSettings.Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
|
||||
return requestRule is null
|
||||
? defaultRule
|
||||
: new RiskRuleSetting(
|
||||
defaultRule.Key,
|
||||
defaultRule.Label,
|
||||
requestRule.Enabled,
|
||||
requestRule.Threshold,
|
||||
requestRule.WindowMinutes,
|
||||
requestRule.Severity,
|
||||
defaultRule.Description);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
settings.RiskRulesJson = RiskRuleSettings.Serialize(mergedRules);
|
||||
var after = RiskRuleSettings.Read(settings);
|
||||
var changes = after
|
||||
.Select(rule =>
|
||||
{
|
||||
var previous = before.First(item => item.Key == rule.Key);
|
||||
return new
|
||||
{
|
||||
field = rule.Key,
|
||||
label = rule.Label,
|
||||
from = $"{previous.Enabled}/{previous.Threshold}/{previous.WindowMinutes}/{previous.Severity}",
|
||||
to = $"{rule.Enabled}/{rule.Threshold}/{rule.WindowMinutes}/{rule.Severity}",
|
||||
sensitive = false,
|
||||
};
|
||||
})
|
||||
.Where(change => change.from != change.to)
|
||||
.ToArray();
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"risk-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Risk-Regeln wurden aktualisiert.",
|
||||
new { changes },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new AdminRiskRulesResponse(after.Select(ToRiskRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static AdminRiskRuleDto ToRiskRuleDto(RiskRuleSetting rule) =>
|
||||
new(rule.Key, rule.Label, rule.Enabled, rule.Threshold, rule.WindowMinutes, rule.Severity, rule.Description);
|
||||
|
||||
private static string? NormalizeReviewNote(string? reviewNote)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reviewNote))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmedReviewNote = reviewNote.Trim();
|
||||
return trimmedReviewNote.Length <= 500 ? trimmedReviewNote : trimmedReviewNote[..500];
|
||||
}
|
||||
|
||||
private static object[] BuildRiskResolutionChanges(
|
||||
string previousStatus,
|
||||
string currentStatus,
|
||||
string? previousReviewNote,
|
||||
string? currentReviewNote)
|
||||
{
|
||||
var changes = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
field = "status",
|
||||
label = "Status",
|
||||
from = previousStatus,
|
||||
to = currentStatus,
|
||||
sensitive = false,
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.Equals(previousReviewNote, currentReviewNote, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add(new
|
||||
{
|
||||
field = "reviewNote",
|
||||
label = "Review-Notiz",
|
||||
from = string.IsNullOrWhiteSpace(previousReviewNote) ? "keine Notiz" : "Notiz vorhanden",
|
||||
to = string.IsNullOrWhiteSpace(currentReviewNote) ? "keine Notiz" : "Notiz vorhanden",
|
||||
sensitive = true,
|
||||
});
|
||||
}
|
||||
|
||||
return changes.ToArray();
|
||||
}
|
||||
|
||||
private static string? NormalizeRiskFlagStatus(string? status, bool allowAll)
|
||||
{
|
||||
var normalizedStatus = string.IsNullOrWhiteSpace(status) ? "open" : status.Trim().ToLowerInvariant();
|
||||
return normalizedStatus switch
|
||||
{
|
||||
"open" or "resolved" or "dismissed" => normalizedStatus,
|
||||
"all" when allowAll => normalizedStatus,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using Backend.Contracts;
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static class AdminRiskFlagMappings
|
||||
{
|
||||
public static AdminRiskFlagDto ToDto(RiskFlag item) =>
|
||||
new(
|
||||
item.Id,
|
||||
item.Source,
|
||||
item.Type,
|
||||
item.Severity,
|
||||
item.Status,
|
||||
item.Summary,
|
||||
item.TwitchUserId,
|
||||
item.CreatedFromIp,
|
||||
item.CreatedAt,
|
||||
item.MetadataJson,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt,
|
||||
BuildEntityLinks(item));
|
||||
|
||||
public static AdminRiskEntityLinkDto[] BuildEntityLinks(RiskFlag item)
|
||||
{
|
||||
var links = ReadExplicitLinks(item.MetadataJson).ToList();
|
||||
if (links.Count > 0)
|
||||
{
|
||||
return links.ToArray();
|
||||
}
|
||||
|
||||
var query = Uri.EscapeDataString(item.TwitchUserId ?? item.Summary);
|
||||
return item.Source.ToLowerInvariant() switch
|
||||
{
|
||||
"clip" => [new AdminRiskEntityLinkDto("Clips öffnen", "clip", item.TwitchUserId ?? item.Id.ToString(), $"/admin/clips?query={query}")],
|
||||
"nomination" => [new AdminRiskEntityLinkDto("Reviews öffnen", "nomination", item.TwitchUserId ?? item.Id.ToString(), $"/admin/reviews?query={query}")],
|
||||
"vote" => [new AdminRiskEntityLinkDto("Voting-Analytics öffnen", "vote", item.TwitchUserId ?? item.Id.ToString(), $"/admin/analytics?query={query}")],
|
||||
"login" => [new AdminRiskEntityLinkDto("Audit-Log öffnen", "session", item.TwitchUserId ?? item.Id.ToString(), $"/admin/users-logs?query={query}")],
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<AdminRiskEntityLinkDto> ReadExplicitLinks(string metadataJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(metadataJson))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(metadataJson);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
if (TryReadEntityLinks(document.RootElement, out var entityLinks))
|
||||
{
|
||||
foreach (var link in entityLinks)
|
||||
{
|
||||
yield return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadEntityLinks(JsonElement root, out AdminRiskEntityLinkDto[] links)
|
||||
{
|
||||
links = [];
|
||||
if (root.ValueKind != JsonValueKind.Object || !TryGetProperty(root, "entityLinks", out var entityLinks) || entityLinks.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
links = entityLinks.EnumerateArray()
|
||||
.Where(item => item.ValueKind == JsonValueKind.Object)
|
||||
.Select(item => new AdminRiskEntityLinkDto(
|
||||
ReadString(item, "label"),
|
||||
ReadString(item, "entityType"),
|
||||
ReadString(item, "entityId"),
|
||||
ReadString(item, "to")))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Label) && !string.IsNullOrWhiteSpace(item.To))
|
||||
.ToArray();
|
||||
|
||||
return links.Length > 0;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement root, string propertyName, out JsonElement value)
|
||||
{
|
||||
foreach (var property in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ReadString(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(root, propertyName, out var value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString() ?? string.Empty,
|
||||
JsonValueKind.Number => value.GetRawText(),
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> CreateCandidate(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertCandidateRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var validationError = ValidateCandidateRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
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 normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|
||||
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
var candidate = new Candidate
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
CategoryId = request.CategoryId,
|
||||
DisplayName = normalizedDisplayName,
|
||||
ChannelSlug = normalizedChannelSlug,
|
||||
Platform = request.Platform.Trim(),
|
||||
};
|
||||
|
||||
db.Candidates.Add(candidate);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"candidate.create",
|
||||
"candidate",
|
||||
request.DisplayName.Trim(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.CategoryId, request.Platform },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCandidate(
|
||||
HttpContext context,
|
||||
int candidateId,
|
||||
UpsertCandidateRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var validationError = ValidateCandidateRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
|
||||
if (candidate is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||
item.Id == request.CategoryId && item.SeasonId == candidate.SeasonId);
|
||||
if (targetCategory is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
|
||||
}
|
||||
|
||||
var normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == candidate.SeasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
&& item.Id != candidateId
|
||||
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|
||||
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
candidate.CategoryId = request.CategoryId;
|
||||
candidate.DisplayName = normalizedDisplayName;
|
||||
candidate.ChannelSlug = normalizedChannelSlug;
|
||||
candidate.Platform = request.Platform.Trim();
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"candidate.update",
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||
new { request.CategoryId, request.Platform },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCandidate(
|
||||
HttpContext context,
|
||||
int candidateId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
|
||||
if (candidate is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.Candidates.Remove(candidate);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"candidate.delete",
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
|
||||
new { candidate.CategoryId, candidate.Platform },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, candidateId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> CreateCategory(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertCategoryRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var validationError = ValidateCategoryRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedSlug = request.Slug.Trim();
|
||||
if (await db.Categories.AnyAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.Slug.ToLower() == normalizedSlug.ToLower()))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." });
|
||||
}
|
||||
|
||||
var category = new Category
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
GroupName = request.GroupName.Trim(),
|
||||
Name = request.Name.Trim(),
|
||||
Slug = normalizedSlug,
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
};
|
||||
|
||||
db.Categories.Add(category);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category.create",
|
||||
"category",
|
||||
request.Slug.Trim(),
|
||||
$"Kategorie {request.Name.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.GroupName, request.SortOrder },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCategory(
|
||||
HttpContext context,
|
||||
int categoryId,
|
||||
UpsertCategoryRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var validationError = ValidateCategoryRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
|
||||
if (category is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedSlug = request.Slug.Trim();
|
||||
if (await db.Categories.AnyAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.Id != categoryId
|
||||
&& item.Slug.ToLower() == normalizedSlug.ToLower()))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." });
|
||||
}
|
||||
|
||||
category.GroupName = request.GroupName.Trim();
|
||||
category.Name = request.Name.Trim();
|
||||
category.Slug = normalizedSlug;
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category.update",
|
||||
"category",
|
||||
category.Id.ToString(),
|
||||
$"Kategorie {request.Name.Trim()} wurde aktualisiert.",
|
||||
new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCategory(
|
||||
HttpContext context,
|
||||
int categoryId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
|
||||
if (category is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync();
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
|
||||
db.Categories.Remove(category);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category.delete",
|
||||
"category",
|
||||
category.Id.ToString(),
|
||||
$"Kategorie {category.Name} wurde gelöscht.",
|
||||
new { removedCandidates = candidates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, categoryId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> CreateSeason(
|
||||
HttpContext context,
|
||||
CreateSeasonRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var validationError = ValidateSeasonRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
if (await db.Seasons.AnyAsync(item => item.Year == request.Year))
|
||||
{
|
||||
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
|
||||
}
|
||||
|
||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||
|
||||
var season = new Season
|
||||
{
|
||||
Year = request.Year,
|
||||
Name = request.Name.Trim(),
|
||||
ShowStreamUrl = showStreamUrl,
|
||||
CurrentPhase = request.CurrentPhase.Trim(),
|
||||
IsCurrent = request.IsCurrent,
|
||||
IsCommunityOnly = request.IsCommunityOnly,
|
||||
NominationStartsAt = request.NominationStartsAt,
|
||||
NominationEndsAt = request.NominationEndsAt,
|
||||
VotingStartsAt = request.VotingStartsAt,
|
||||
VotingEndsAt = request.VotingEndsAt,
|
||||
ReviewStartsAt = request.ReviewStartsAt,
|
||||
ReviewEndsAt = request.ReviewEndsAt,
|
||||
ShowDate = request.ShowDate,
|
||||
ShowStartsAt = request.ShowStartsAt,
|
||||
};
|
||||
|
||||
db.Seasons.Add(season);
|
||||
var copiedCategoryCount = 0;
|
||||
if (request.CopyStructureFromSeasonId is { } sourceSeasonId)
|
||||
{
|
||||
if (sourceSeasonId <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A valid source season is required for copying structure." });
|
||||
}
|
||||
|
||||
var sourceSeasonExists = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.AnyAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||
if (!sourceSeasonExists)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Source season for structure copy was not found." });
|
||||
}
|
||||
|
||||
var sourceCategories = await db.Categories
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == sourceSeasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
copiedCategoryCount = sourceCategories.Length;
|
||||
foreach (var category in sourceCategories)
|
||||
{
|
||||
db.Categories.Add(new Category
|
||||
{
|
||||
Season = season,
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
Description = category.Description,
|
||||
SortOrder = category.SortOrder,
|
||||
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var readinessIssues = BuildNewSeasonReadinessIssues(
|
||||
request.CurrentPhase,
|
||||
request.IsCurrent,
|
||||
copiedCategoryCount);
|
||||
if (readinessIssues.Length > 0)
|
||||
{
|
||||
return CreateReadinessError(readinessIssues);
|
||||
}
|
||||
|
||||
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent, null, context.RequestAborted);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"season.create",
|
||||
"season",
|
||||
request.Year.ToString(),
|
||||
copiedCategoryCount > 0
|
||||
? $"Season {request.Year} wurde angelegt und {copiedCategoryCount} Kategorien wurden kopiert."
|
||||
: $"Season {request.Year} wurde angelegt.",
|
||||
new
|
||||
{
|
||||
request.IsCurrent,
|
||||
request.IsCommunityOnly,
|
||||
showStreamUrl,
|
||||
request.CurrentPhase,
|
||||
request.ShowDate,
|
||||
request.ShowStartsAt,
|
||||
request.CopyStructureFromSeasonId,
|
||||
copiedCategoryCount,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId = season.Id, copiedCategoryCount });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> DeleteSeason(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (season.IsCurrent)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das öffentlich aktive Award-Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich." });
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted);
|
||||
|
||||
var deletionSummary = await DeleteSeasonRelationsAsync(db, seasonId, context.RequestAborted);
|
||||
|
||||
db.Seasons.Remove(season);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"season.delete",
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Season {season.Year} wurde gelöscht.",
|
||||
new
|
||||
{
|
||||
season.Year,
|
||||
deletionSummary.DeletedVoteEntries,
|
||||
deletionSummary.DeletedBallots,
|
||||
deletionSummary.DeletedResults,
|
||||
deletionSummary.DeletedNominations,
|
||||
deletionSummary.DeletedClips,
|
||||
deletionSummary.DeletedRiskFlags,
|
||||
deletionSummary.DeletedCandidates,
|
||||
deletionSummary.DeletedCategories,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
await transaction.CommitAsync(context.RequestAborted);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
deleted = true,
|
||||
seasonId,
|
||||
season.Year,
|
||||
deletedVoteEntries = deletionSummary.DeletedVoteEntries,
|
||||
deletedBallots = deletionSummary.DeletedBallots,
|
||||
deletedResults = deletionSummary.DeletedResults,
|
||||
deletedNominations = deletionSummary.DeletedNominations,
|
||||
deletedClips = deletionSummary.DeletedClips,
|
||||
deletedRiskFlags = deletionSummary.DeletedRiskFlags,
|
||||
deletedCandidates = deletionSummary.DeletedCandidates,
|
||||
deletedCategories = deletionSummary.DeletedCategories,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private sealed record SeasonDeletionSummary(
|
||||
int DeletedVoteEntries,
|
||||
int DeletedBallots,
|
||||
int DeletedResults,
|
||||
int DeletedNominations,
|
||||
int DeletedClips,
|
||||
int DeletedRiskFlags,
|
||||
int DeletedCandidates,
|
||||
int DeletedCategories);
|
||||
|
||||
private static async Task<SeasonDeletionSummary> DeleteSeasonRelationsAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ballotIds = await db.VoteBallots
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => item.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var deletedVoteEntries = ballotIds.Length == 0
|
||||
? 0
|
||||
: await db.VoteEntries
|
||||
.Where(item => ballotIds.Contains(item.BallotId))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
var deletedBallots = await db.VoteBallots
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
var deletedResults = await db.Results
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
var deletedNominations = await db.Nominations
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
var deletedClips = await db.ClipSubmissions
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
var deletedRiskFlags = await db.RiskFlags
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
var deletedCandidates = await db.Candidates
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
var deletedCategories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
return new SeasonDeletionSummary(
|
||||
deletedVoteEntries,
|
||||
deletedBallots,
|
||||
deletedResults,
|
||||
deletedNominations,
|
||||
deletedClips,
|
||||
deletedRiskFlags,
|
||||
deletedCandidates,
|
||||
deletedCategories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetSeasonDetail(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
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(grouping => grouping.Key, grouping => grouping.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.Status == "pending" && item.CandidateText != null)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText!,
|
||||
item.StreamUrl,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
item.CandidateId != null ? item.Candidate!.DisplayName : null,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||
item.StreamUrl,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
item.CandidateId != null ? item.Candidate!.DisplayName : null,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var resultItems = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.Category.SortOrder)
|
||||
.ThenBy(item => item.Category.Name)
|
||||
.Select(item => new AdminAwardResultItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CandidateId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.Platform))
|
||||
.ToArrayAsync();
|
||||
|
||||
var clipSubmissions = await db.ClipSubmissions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminClipSubmissionItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.CandidateId,
|
||||
item.SubmittedByTwitchId,
|
||||
item.ClipUrl,
|
||||
item.Title,
|
||||
item.Creator,
|
||||
item.Platform,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(new AdminSeasonDetailResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
season.Name,
|
||||
NormalizeSeasonStreamUrl(season.ShowStreamUrl),
|
||||
season.CurrentPhase,
|
||||
season.IsCurrent,
|
||||
season.IsCommunityOnly,
|
||||
season.NominationStartsAt,
|
||||
season.NominationEndsAt,
|
||||
season.VotingStartsAt,
|
||||
season.VotingEndsAt,
|
||||
season.ReviewStartsAt,
|
||||
season.ReviewEndsAt,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations,
|
||||
reviewedNominations,
|
||||
resultItems,
|
||||
clipSubmissions));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetSeasons(AwardsDbContext db)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/seasons", GetSeasons).WithName("GetAdminSeasons").WithOpenApi();
|
||||
group.MapPost("/seasons", CreateSeason).WithName("CreateAdminSeason").WithOpenApi();
|
||||
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail).WithName("GetAdminSeasonDetail").WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}", UpdateSeason).WithName("UpdateAdminSeason").WithOpenApi();
|
||||
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason).WithName("DeleteAdminSeason").WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory).WithName("CreateAdminCategory").WithOpenApi();
|
||||
group.MapPut("/categories/{categoryId:int}", UpdateCategory).WithName("UpdateAdminCategory").WithOpenApi();
|
||||
group.MapDelete("/categories/{categoryId:int}", DeleteCategory).WithName("DeleteAdminCategory").WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate).WithName("CreateAdminCandidate").WithOpenApi();
|
||||
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate).WithName("UpdateAdminCandidate").WithOpenApi();
|
||||
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate).WithName("DeleteAdminCandidate").WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/results", SetResult).WithName("SetAdminResult").WithOpenApi();
|
||||
group.MapDelete("/results/{resultId:int}", DeleteResult).WithName("DeleteAdminResult").WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private const int MaxCategoryGroupNameLength = 80;
|
||||
private const int MaxCategoryNameLength = 120;
|
||||
private const int MaxCategorySlugLength = 120;
|
||||
private const int MaxCategoryDescriptionLength = 600;
|
||||
private const int MaxCandidateDisplayNameLength = 120;
|
||||
private const int MaxCandidateChannelSlugLength = 120;
|
||||
private const int MaxCandidatePlatformLength = 60;
|
||||
|
||||
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||
{
|
||||
if (request.Year < 2020 || request.Year > 2100)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Please provide a valid award year." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Season name and current phase are required." });
|
||||
}
|
||||
|
||||
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
|
||||
}
|
||||
|
||||
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||
request.NominationStartsAt,
|
||||
request.NominationEndsAt,
|
||||
request.VotingStartsAt,
|
||||
request.VotingEndsAt,
|
||||
request.ReviewStartsAt,
|
||||
request.ReviewEndsAt,
|
||||
request.ShowDate))
|
||||
{
|
||||
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult? ValidateSeasonRequest(UpdateSeasonRequest request)
|
||||
{
|
||||
if (request.Year < 2020 || request.Year > 2100)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Please provide a valid award year." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Season name and current phase are required." });
|
||||
}
|
||||
|
||||
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
|
||||
}
|
||||
|
||||
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||
request.NominationStartsAt,
|
||||
request.NominationEndsAt,
|
||||
request.VotingStartsAt,
|
||||
request.VotingEndsAt,
|
||||
request.ReviewStartsAt,
|
||||
request.ReviewEndsAt,
|
||||
request.ShowDate))
|
||||
{
|
||||
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeSeasonStreamUrl(string? showStreamUrl)
|
||||
{
|
||||
return SeasonMappings.NormalizeSeasonStreamUrl(showStreamUrl);
|
||||
}
|
||||
|
||||
private static bool IsKnownSeasonPhase(string? currentPhase)
|
||||
{
|
||||
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
return value.Contains("show")
|
||||
|| value.Contains("abgeschlossen")
|
||||
|| value.Contains("archiv")
|
||||
|| value.Contains("complete")
|
||||
|| value.Contains("ended")
|
||||
|| value.Contains("review")
|
||||
|| value.Contains("auswert")
|
||||
|| value.Contains("vot")
|
||||
|| value.Contains("nomin");
|
||||
}
|
||||
|
||||
private static async Task UnsetOtherCurrentSeasonsAsync(
|
||||
AwardsDbContext db,
|
||||
bool shouldUnsetOthers,
|
||||
int? seasonIdToKeep,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!shouldUnsetOthers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var activeSeasons = await db.Seasons
|
||||
.Where(item => item.IsCurrent && (!seasonIdToKeep.HasValue || item.Id != seasonIdToKeep.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var activeSeason in activeSeasons)
|
||||
{
|
||||
activeSeason.IsCurrent = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CreateReadinessError(IEnumerable<string> issues)
|
||||
{
|
||||
var issueList = issues.ToArray();
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = $"Public-/Archiv-Readiness blockiert: {string.Join(" ", issueList)}",
|
||||
issues = issueList,
|
||||
});
|
||||
}
|
||||
|
||||
private static string[] BuildNewSeasonReadinessIssues(
|
||||
string currentPhase,
|
||||
bool isCurrent,
|
||||
int copiedCategoryCount)
|
||||
{
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||
if (!isCurrent && !needsWinnerReadiness)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
if (copiedCategoryCount <= 0)
|
||||
{
|
||||
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
||||
}
|
||||
|
||||
if (needsCandidateReadiness)
|
||||
{
|
||||
issues.Add("Kandidaten muessen vor dieser Phase fuer alle Kategorien gepflegt sein.");
|
||||
}
|
||||
|
||||
if (needsWinnerReadiness)
|
||||
{
|
||||
issues.Add("Abgeschlossen ist erst moeglich, wenn jede Kategorie einen Gewinner hat.");
|
||||
}
|
||||
|
||||
return issues.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<string[]> BuildSeasonReadinessIssuesAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
string currentPhase,
|
||||
bool isCurrent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||
if (!isCurrent && !needsWinnerReadiness)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var categoryIds = await db.Categories
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => item.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var issues = new List<string>();
|
||||
if (categoryIds.Length == 0)
|
||||
{
|
||||
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
||||
}
|
||||
|
||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
var categoriesWithCandidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => item.CategoryId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||
if (emptyCategories > 0)
|
||||
{
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWinnerReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
var categoriesWithResults = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => item.CategoryId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults);
|
||||
if (missingResults > 0)
|
||||
{
|
||||
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||
}
|
||||
}
|
||||
|
||||
return issues.ToArray();
|
||||
}
|
||||
|
||||
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
||||
{
|
||||
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
||||
|| string.Equals(phaseKey, "completed", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool RequiresWinnerReadiness(string phaseKey)
|
||||
{
|
||||
return string.Equals(phaseKey, "completed", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IResult? ValidateCategoryRequest(UpsertCategoryRequest request)
|
||||
{
|
||||
var groupName = request.GroupName.Trim();
|
||||
var name = request.Name.Trim();
|
||||
var slug = request.Slug.Trim();
|
||||
var description = request.Description.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > MaxCategoryGroupNameLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Category group name is required and must stay below {MaxCategoryGroupNameLength} characters." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Length > MaxCategoryNameLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Category name is required and must stay below {MaxCategoryNameLength} characters." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(slug) || slug.Length > MaxCategorySlugLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Category slug is required and must stay below {MaxCategorySlugLength} characters." });
|
||||
}
|
||||
|
||||
if (!slug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_'))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Category slug contains unsupported characters." });
|
||||
}
|
||||
|
||||
if (description.Length > MaxCategoryDescriptionLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Category description must stay below {MaxCategoryDescriptionLength} characters." });
|
||||
}
|
||||
|
||||
if (request.SortOrder is < 0 or > 500)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Category sort order must be between 0 and 500." });
|
||||
}
|
||||
|
||||
if (request.MaxNomineesPerUser is < 1 or > 10)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult? ValidateCandidateRequest(UpsertCandidateRequest request)
|
||||
{
|
||||
var displayName = request.DisplayName.Trim();
|
||||
var channelSlug = request.ChannelSlug.Trim();
|
||||
var platform = request.Platform.Trim();
|
||||
|
||||
if (request.CategoryId <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A valid category is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > MaxCandidateDisplayNameLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxCandidateDisplayNameLength} characters." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(channelSlug) || channelSlug.Length > MaxCandidateChannelSlugLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Channel slug is required and must stay below {MaxCandidateChannelSlugLength} characters." });
|
||||
}
|
||||
|
||||
if (!channelSlug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_' or '.'))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Channel slug contains unsupported characters." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(platform) || platform.Length > MaxCandidatePlatformLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> SetResult(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
SetAwardResultRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var category = await db.Categories
|
||||
.AsNoTracking()
|
||||
.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 = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.Id == request.CandidateId
|
||||
&& item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId);
|
||||
if (candidate is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||
}
|
||||
|
||||
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId);
|
||||
|
||||
if (existingResult is null)
|
||||
{
|
||||
existingResult = new AwardResult
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
CategoryId = request.CategoryId,
|
||||
CandidateId = request.CandidateId,
|
||||
CategoryName = category.Name,
|
||||
};
|
||||
db.Results.Add(existingResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingResult.CandidateId = request.CandidateId;
|
||||
existingResult.CategoryName = category.Name;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"result.set",
|
||||
"result",
|
||||
$"{seasonId}:{request.CategoryId}",
|
||||
$"Gewinner für {category.Name} wurde gesetzt.",
|
||||
new
|
||||
{
|
||||
seasonId,
|
||||
categoryId = request.CategoryId,
|
||||
candidateId = request.CandidateId,
|
||||
candidateName = candidate.DisplayName,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
resultId = existingResult.Id,
|
||||
seasonId,
|
||||
categoryId = request.CategoryId,
|
||||
candidateId = request.CandidateId,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteResult(
|
||||
HttpContext context,
|
||||
int resultId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var result = await db.Results
|
||||
.Include(item => item.Category)
|
||||
.Include(item => item.Candidate)
|
||||
.FirstOrDefaultAsync(item => item.Id == resultId);
|
||||
if (result is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.Results.Remove(result);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"result.delete",
|
||||
"result",
|
||||
result.Id.ToString(),
|
||||
$"Gewinner für {result.Category.Name} wurde entfernt.",
|
||||
new
|
||||
{
|
||||
result.SeasonId,
|
||||
result.CategoryId,
|
||||
result.CandidateId,
|
||||
candidateName = result.Candidate.DisplayName,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, resultId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> UpdateSeason(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateSeasonRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var validationError = ValidateSeasonRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
if (await db.Seasons.AnyAsync(item => item.Id != seasonId && item.Year == request.Year))
|
||||
{
|
||||
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
|
||||
}
|
||||
|
||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||
|
||||
var wasCurrent = season.IsCurrent;
|
||||
var previousPhase = season.CurrentPhase;
|
||||
var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase);
|
||||
var requestedPhaseKey = SeasonMappings.NormalizePhaseKey(request.CurrentPhase);
|
||||
var shouldValidateReadiness = request.IsCurrent
|
||||
|| (string.Equals(requestedPhaseKey, "completed", StringComparison.Ordinal)
|
||||
&& !string.Equals(previousPhaseKey, "completed", StringComparison.Ordinal));
|
||||
if (shouldValidateReadiness)
|
||||
{
|
||||
var readinessIssues = await BuildSeasonReadinessIssuesAsync(
|
||||
db,
|
||||
seasonId,
|
||||
request.CurrentPhase,
|
||||
request.IsCurrent,
|
||||
context.RequestAborted);
|
||||
if (readinessIssues.Length > 0)
|
||||
{
|
||||
return CreateReadinessError(readinessIssues);
|
||||
}
|
||||
}
|
||||
|
||||
season.Year = request.Year;
|
||||
season.Name = request.Name.Trim();
|
||||
season.ShowStreamUrl = showStreamUrl;
|
||||
season.CurrentPhase = request.CurrentPhase.Trim();
|
||||
season.IsCommunityOnly = request.IsCommunityOnly;
|
||||
season.NominationStartsAt = request.NominationStartsAt;
|
||||
season.NominationEndsAt = request.NominationEndsAt;
|
||||
season.VotingStartsAt = request.VotingStartsAt;
|
||||
season.VotingEndsAt = request.VotingEndsAt;
|
||||
season.ReviewStartsAt = request.ReviewStartsAt;
|
||||
season.ReviewEndsAt = request.ReviewEndsAt;
|
||||
season.ShowDate = request.ShowDate;
|
||||
season.ShowStartsAt = request.ShowStartsAt;
|
||||
|
||||
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent && !wasCurrent, seasonId, context.RequestAborted);
|
||||
|
||||
season.IsCurrent = request.IsCurrent;
|
||||
var actionType = "season.update";
|
||||
var summary = $"Season {season.Year} wurde aktualisiert.";
|
||||
if (!string.Equals(previousPhase.Trim(), season.CurrentPhase, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
actionType = "season.phase.update";
|
||||
summary = $"Phase fuer Season {season.Year} wurde auf {season.CurrentPhase} gesetzt.";
|
||||
}
|
||||
else if (wasCurrent != request.IsCurrent)
|
||||
{
|
||||
actionType = "season.public.update";
|
||||
summary = request.IsCurrent
|
||||
? $"Season {season.Year} wurde als Public-Kontext aktiviert."
|
||||
: $"Season {season.Year} wurde aus dem Public-Kontext entfernt.";
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
actionType,
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
summary,
|
||||
new
|
||||
{
|
||||
request.Year,
|
||||
request.Name,
|
||||
showStreamUrl,
|
||||
previousPhase,
|
||||
request.CurrentPhase,
|
||||
wasCurrent,
|
||||
request.IsCurrent,
|
||||
request.IsCommunityOnly,
|
||||
request.ShowDate,
|
||||
request.ShowStartsAt,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId = season.Id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Text.Json;
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Security;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static class AdminSiteSettingsEndpoints
|
||||
{
|
||||
private const string FallbackMaintenanceTitle = "Sternenpause";
|
||||
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||
|
||||
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/site-settings", GetSiteSettings).WithName("GetAdminSiteSettings").WithOpenApi();
|
||||
group.MapPut("/site-settings", UpdateSiteSettings).WithName("UpdateAdminSiteSettings").WithOpenApi();
|
||||
group.MapGet("/operational-settings", GetOperationalSettings).WithName("GetAdminOperationalSettings").WithOpenApi();
|
||||
group.MapPut("/operational-settings", UpdateOperationalSettings).WithName("UpdateAdminOperationalSettings").WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSiteSettings(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new AdminSiteSettingsResponse(
|
||||
settings.HostDisplayName,
|
||||
settings.HostTagline,
|
||||
settings.NewsletterUrl,
|
||||
settings.PrivacyEmail,
|
||||
settings.PrivacyPolicyContent,
|
||||
settings.PrivacyPolicyUpdatedBy,
|
||||
settings.PrivacyPolicyUpdatedAt,
|
||||
settings.ImprintUrl,
|
||||
settings.ContactUrl,
|
||||
settings.SponsorsUrl,
|
||||
SeasonMappings.ReadSocialLinks(settings),
|
||||
SeasonMappings.ReadFaqItems(settings)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSiteSettings(
|
||||
HttpContext context,
|
||||
UpdateSiteSettingsRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
if (!AdminRoles.CanManageContent(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Landingpage content requires a content admin, admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||
settings.HostTagline = request.HostTagline.Trim();
|
||||
settings.NewsletterUrl = request.NewsletterUrl.Trim();
|
||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||
settings.PrivacyPolicyContent = trimmedPrivacyContent;
|
||||
if (privacyChanged)
|
||||
{
|
||||
settings.PrivacyPolicyUpdatedBy = session.DisplayName.Trim();
|
||||
settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
settings.ImprintUrl = request.ImprintUrl.Trim();
|
||||
settings.ContactUrl = request.ContactUrl.Trim();
|
||||
settings.SponsorsUrl = request.SponsorsUrl.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"site-settings.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Public Site Settings wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
settings.HostDisplayName,
|
||||
privacyChanged,
|
||||
socialLinkCount = request.SocialLinks?.Length ?? 0,
|
||||
faqCount = request.Faq?.Length ?? 0,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||
usesDatabaseDemo,
|
||||
usesDatabaseDemo ? settings.DemoLoginEnabled : IsDemoLoginEnabled(configuration),
|
||||
usesDatabaseDemo ? settings.DemoLoginEmail : ReadDemoLoginIdentifier(configuration),
|
||||
usesDatabaseDemo ? HasDatabaseDemoCredentials(settings) : !string.IsNullOrWhiteSpace(ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD")),
|
||||
usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"),
|
||||
usesDatabaseDemo ? settings.DemoLoginDisplayName : ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||
? FallbackMaintenanceMessage
|
||||
: settings.MaintenanceMessage));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOperationalSettings(
|
||||
HttpContext context,
|
||||
UpdateOperationalSettingsRequest request,
|
||||
AwardsDbContext db,
|
||||
IConfiguration configuration,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
if (!AdminRoles.CanManageOperationalSettings(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Demo-Zugang und Wartungsmodus können nur Owner ändern." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = CreateOperationalSettingsSnapshot(settings);
|
||||
var loginIdentifier = request.DemoLoginEmail.Trim();
|
||||
var twitchUserId = request.DemoLoginTwitchUserId.Trim();
|
||||
var displayName = request.DemoLoginDisplayName.Trim();
|
||||
var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty;
|
||||
var fallbackPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
|
||||
if (request.DemoLoginEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(loginIdentifier)
|
||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Demo-Login braucht Login, Twitch-ID und Anzeigenamen." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newPassword)
|
||||
&& !HasDatabaseDemoCredentials(settings)
|
||||
&& string.IsNullOrWhiteSpace(fallbackPassword))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte setze beim ersten Aktivieren ein Demo-Passwort." });
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 12)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 12 Zeichen lang sein." });
|
||||
}
|
||||
|
||||
settings.DemoLoginManagedByDatabase = true;
|
||||
settings.DemoLoginEnabled = request.DemoLoginEnabled;
|
||||
settings.DemoLoginEmail = loginIdentifier;
|
||||
settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId;
|
||||
settings.DemoLoginDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Jayuhime Admin" : displayName;
|
||||
|
||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||
? newPassword
|
||||
: request.DemoLoginEnabled && !HasDatabaseDemoCredentials(settings)
|
||||
? fallbackPassword
|
||||
: string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(passwordToPersist))
|
||||
{
|
||||
var credentials = DemoCredentialHasher.HashPassword(passwordToPersist);
|
||||
settings.DemoLoginPasswordHash = credentials.Hash;
|
||||
settings.DemoLoginPasswordSalt = credentials.Salt;
|
||||
}
|
||||
|
||||
settings.MaintenanceModeEnabled = request.MaintenanceModeEnabled;
|
||||
settings.MaintenanceTitle = NormalizeOperationalText(request.MaintenanceTitle, FallbackMaintenanceTitle, 120);
|
||||
settings.MaintenanceMessage = NormalizeOperationalText(
|
||||
request.MaintenanceMessage,
|
||||
FallbackMaintenanceMessage,
|
||||
600);
|
||||
|
||||
var changes = BuildOperationalSettingChanges(
|
||||
before,
|
||||
CreateOperationalSettingsSnapshot(settings),
|
||||
!string.IsNullOrWhiteSpace(passwordToPersist));
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"operational-settings.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
settings.DemoLoginEnabled,
|
||||
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
||||
settings.MaintenanceModeEnabled,
|
||||
changes,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
demoLoginPasswordSet = HasDatabaseDemoCredentials(settings),
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeOperationalText(string value, string fallback, int maxLength)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
{
|
||||
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
|
||||
?? configuration["DemoAdmin:Enabled"];
|
||||
|
||||
return bool.TryParse(rawValue, out var enabled) && enabled;
|
||||
}
|
||||
|
||||
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
|
||||
|
||||
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
|
||||
{
|
||||
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
|
||||
return string.IsNullOrWhiteSpace(configuredLogin)
|
||||
? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL")
|
||||
: configuredLogin;
|
||||
}
|
||||
|
||||
private static bool HasDatabaseDemoCredentials(SiteSettings settings) =>
|
||||
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
|
||||
|
||||
private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings) =>
|
||||
new(
|
||||
settings.DemoLoginManagedByDatabase,
|
||||
settings.DemoLoginEnabled,
|
||||
settings.DemoLoginEmail,
|
||||
HasDatabaseDemoCredentials(settings),
|
||||
settings.DemoLoginTwitchUserId,
|
||||
settings.DemoLoginDisplayName,
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||
|
||||
private static object[] BuildOperationalSettingChanges(
|
||||
OperationalSettingsSnapshot before,
|
||||
OperationalSettingsSnapshot after,
|
||||
bool passwordChanged)
|
||||
{
|
||||
var changes = new List<object>();
|
||||
|
||||
AddOperationalChange(changes, "demoLoginManagedByDatabase", "Demo Quelle", before.DemoLoginManagedByDatabase, after.DemoLoginManagedByDatabase);
|
||||
AddOperationalChange(changes, "demoLoginEnabled", "Demo Login", before.DemoLoginEnabled, after.DemoLoginEnabled);
|
||||
AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail);
|
||||
AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId);
|
||||
AddOperationalChange(changes, "demoLoginDisplayName", "Demo Anzeigename", before.DemoLoginDisplayName, after.DemoLoginDisplayName);
|
||||
|
||||
if (passwordChanged)
|
||||
{
|
||||
changes.Add(new
|
||||
{
|
||||
field = "demoLoginPassword",
|
||||
label = "Demo Passwort",
|
||||
@from = before.DemoLoginPasswordSet ? "gesetzt" : "nicht gesetzt",
|
||||
to = "neu gesetzt",
|
||||
sensitive = true,
|
||||
});
|
||||
}
|
||||
|
||||
AddOperationalChange(changes, "maintenanceModeEnabled", "Wartungsmodus", before.MaintenanceModeEnabled, after.MaintenanceModeEnabled);
|
||||
AddOperationalChange(changes, "maintenanceTitle", "Wartungstitel", before.MaintenanceTitle, after.MaintenanceTitle);
|
||||
AddOperationalChange(changes, "maintenanceMessage", "Wartungstext", before.MaintenanceMessage, after.MaintenanceMessage);
|
||||
|
||||
return changes.ToArray();
|
||||
}
|
||||
|
||||
private static void AddOperationalChange<T>(
|
||||
ICollection<object> changes,
|
||||
string field,
|
||||
string label,
|
||||
T before,
|
||||
T after)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(before, after))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
changes.Add(new
|
||||
{
|
||||
field,
|
||||
label,
|
||||
@from = FormatOperationalAuditValue(before),
|
||||
to = FormatOperationalAuditValue(after),
|
||||
sensitive = false,
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatOperationalAuditValue<T>(T value)
|
||||
{
|
||||
if (value is bool booleanValue)
|
||||
{
|
||||
return booleanValue ? "aktiv" : "aus";
|
||||
}
|
||||
|
||||
var text = Convert.ToString(value)?.Trim() ?? string.Empty;
|
||||
return string.IsNullOrWhiteSpace(text) ? "leer" : text;
|
||||
}
|
||||
|
||||
private sealed record OperationalSettingsSnapshot(
|
||||
bool DemoLoginManagedByDatabase,
|
||||
bool DemoLoginEnabled,
|
||||
string DemoLoginEmail,
|
||||
bool DemoLoginPasswordSet,
|
||||
string DemoLoginTwitchUserId,
|
||||
string DemoLoginDisplayName,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> DeleteMyParticipationData(
|
||||
HttpContext context,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var twitchUserId = session.TwitchUserId;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted);
|
||||
|
||||
var ballotIds = await db.VoteBallots
|
||||
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||
.Select(item => item.Id)
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
var deletedVoteEntries = ballotIds.Length == 0
|
||||
? 0
|
||||
: await db.VoteEntries
|
||||
.Where(item => ballotIds.Contains(item.BallotId))
|
||||
.ExecuteDeleteAsync(context.RequestAborted);
|
||||
|
||||
var deletedBallots = await db.VoteBallots
|
||||
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||
.ExecuteDeleteAsync(context.RequestAborted);
|
||||
|
||||
var deletedNominations = await db.Nominations
|
||||
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||
.ExecuteDeleteAsync(context.RequestAborted);
|
||||
|
||||
var deletedClips = await db.ClipSubmissions
|
||||
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||
.ExecuteDeleteAsync(context.RequestAborted);
|
||||
|
||||
var deletedRiskFlags = await db.RiskFlags
|
||||
.Where(item => item.TwitchUserId == twitchUserId)
|
||||
.ExecuteDeleteAsync(context.RequestAborted);
|
||||
|
||||
var disabledSessions = await db.UserSessions
|
||||
.Where(item => item.TwitchUserId == twitchUserId)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(item => item.IsActive, false),
|
||||
context.RequestAborted);
|
||||
|
||||
await transaction.CommitAsync(context.RequestAborted);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
deleted = true,
|
||||
twitchUserId,
|
||||
deletedVoteEntries,
|
||||
deletedBallots,
|
||||
deletedNominations,
|
||||
deletedClips,
|
||||
deletedRiskFlags,
|
||||
disabledSessions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Security;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> DemoLogin(
|
||||
HttpContext context,
|
||||
AwardsDbContext db,
|
||||
IConfiguration configuration,
|
||||
DemoLoginRequest request,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
{
|
||||
var login = request.Login?.Trim() ?? request.Email?.Trim() ?? string.Empty;
|
||||
var password = request.Password ?? string.Empty;
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var databaseDemoConfigured = settings is not null
|
||||
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
|
||||
|
||||
string twitchUserId;
|
||||
string displayName;
|
||||
bool credentialsMatch;
|
||||
|
||||
if (databaseDemoConfigured && settings is not null)
|
||||
{
|
||||
if (!settings.DemoLoginEnabled)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!HasDatabaseDemoCredentials(settings)
|
||||
|| string.IsNullOrWhiteSpace(settings.DemoLoginTwitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(settings.DemoLoginDisplayName))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Demo login is not fully configured." },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
settings.DemoLoginEmail,
|
||||
settings.DemoLoginTwitchUserId,
|
||||
settings.DemoLoginDisplayName)
|
||||
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||
displayName = settings.DemoLoginDisplayName.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!IsDemoLoginEnabled(configuration))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var configuredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuredLogin)
|
||||
|| string.IsNullOrWhiteSpace(configuredPassword)
|
||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Demo login is not fully configured." },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
configuredLogin,
|
||||
configuredEmail,
|
||||
twitchUserId,
|
||||
displayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
|
||||
twitchUserId = twitchUserId.Trim();
|
||||
displayName = displayName.Trim();
|
||||
}
|
||||
|
||||
if (!credentialsMatch)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var session = await userSessionService.CreateSessionAsync(
|
||||
twitchUserId,
|
||||
displayName,
|
||||
AdminRoles.Owner,
|
||||
requestMetadata,
|
||||
context.RequestAborted);
|
||||
|
||||
var rapidDemoLoginRule = await riskRuleService.GetRuleAsync("rapid_demo_login_ip", context.RequestAborted);
|
||||
var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync(
|
||||
requestMetadata.ClientIp,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-rapidDemoLoginRule.WindowMinutes),
|
||||
context.RequestAborted);
|
||||
|
||||
if (rapidDemoLoginRule.Enabled && recentSessionsFromIp >= rapidDemoLoginRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
null,
|
||||
session.TwitchUserId,
|
||||
"login",
|
||||
"rapid_demo_login_ip",
|
||||
rapidDemoLoginRule.Severity,
|
||||
"Mehrere Demo-Admin-Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
|
||||
requestMetadata,
|
||||
new
|
||||
{
|
||||
recentSessionsFromIp,
|
||||
threshold = rapidDemoLoginRule.Threshold,
|
||||
windowMinutes = rapidDemoLoginRule.WindowMinutes,
|
||||
entityLinks = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
label = "Audit-Log öffnen",
|
||||
entityType = "session",
|
||||
entityId = session.TwitchUserId,
|
||||
to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}",
|
||||
},
|
||||
},
|
||||
},
|
||||
context.RequestAborted);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(ToAuthSessionDto(session));
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
{
|
||||
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
|
||||
?? configuration["DemoAdmin:Enabled"];
|
||||
|
||||
return bool.TryParse(rawValue, out var enabled) && enabled;
|
||||
}
|
||||
|
||||
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
|
||||
|
||||
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
|
||||
{
|
||||
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
|
||||
return string.IsNullOrWhiteSpace(configuredLogin)
|
||||
? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL")
|
||||
: configuredLogin;
|
||||
}
|
||||
|
||||
private static bool HasDatabaseDemoCredentials(SiteSettings settings) =>
|
||||
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
|
||||
|
||||
private static bool LoginMatchesIdentifier(string login, params string?[] validIdentifiers)
|
||||
{
|
||||
var normalizedLogin = NormalizeLoginIdentifier(login);
|
||||
if (string.IsNullOrWhiteSpace(normalizedLogin))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return validIdentifiers
|
||||
.Select(NormalizeLoginIdentifier)
|
||||
.Any(identifier => string.Equals(normalizedLogin, identifier, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string NormalizeLoginIdentifier(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimStart('@');
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Security;
|
||||
using Backend.Services;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AuthEndpoints
|
||||
{
|
||||
private const int MaxTwitchUserIdLength = 64;
|
||||
private const int MaxDisplayNameLength = 80;
|
||||
|
||||
private static async Task<IResult> DevLogin(
|
||||
HttpContext context,
|
||||
IHostEnvironment environment,
|
||||
LoginRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
{
|
||||
if (!environment.IsDevelopment())
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedTwitchUserId = request.TwitchUserId?.Trim() ?? string.Empty;
|
||||
var normalizedDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||
var normalizedRole = AdminRoles.Normalize(request.Role);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedTwitchUserId) || normalizedTwitchUserId.Length > MaxTwitchUserIdLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Twitch user id is required and must stay below {MaxTwitchUserIdLength} characters." });
|
||||
}
|
||||
|
||||
if (!normalizedTwitchUserId.All(value => char.IsLetterOrDigit(value) || value is '_' or '-'))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Twitch user id contains unsupported characters." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedDisplayName) || normalizedDisplayName.Length > MaxDisplayNameLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxDisplayNameLength} characters." });
|
||||
}
|
||||
|
||||
if (!AdminRoles.IsKnownRole(request.Role))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Role must be viewer, content_admin, admin or owner." });
|
||||
}
|
||||
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var session = await userSessionService.CreateDevSessionAsync(
|
||||
request with
|
||||
{
|
||||
TwitchUserId = normalizedTwitchUserId,
|
||||
DisplayName = normalizedDisplayName,
|
||||
Role = normalizedRole,
|
||||
},
|
||||
requestMetadata,
|
||||
context.RequestAborted);
|
||||
|
||||
var rapidLoginRule = await riskRuleService.GetRuleAsync("rapid_login_ip", context.RequestAborted);
|
||||
var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync(
|
||||
requestMetadata.ClientIp,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-rapidLoginRule.WindowMinutes),
|
||||
context.RequestAborted);
|
||||
|
||||
if (rapidLoginRule.Enabled && recentSessionsFromIp >= rapidLoginRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
null,
|
||||
session.TwitchUserId,
|
||||
"login",
|
||||
"rapid_login_ip",
|
||||
rapidLoginRule.Severity,
|
||||
"Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
|
||||
requestMetadata,
|
||||
new
|
||||
{
|
||||
recentSessionsFromIp,
|
||||
threshold = rapidLoginRule.Threshold,
|
||||
windowMinutes = rapidLoginRule.WindowMinutes,
|
||||
entityLinks = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
label = "Audit-Log öffnen",
|
||||
entityType = "session",
|
||||
entityId = session.TwitchUserId,
|
||||
to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}",
|
||||
},
|
||||
},
|
||||
},
|
||||
context.RequestAborted);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(ToAuthSessionDto(session));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Backend.Common;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AuthEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/auth");
|
||||
|
||||
group.MapPost("/dev-login", DevLogin)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("DevLogin")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/demo-login", DemoLogin)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("DemoLogin")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/session", GetSession)
|
||||
.WithName("GetSession")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/logout", Logout)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("Logout")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapDelete("/me/data", DeleteMyParticipationData)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("DeleteMyParticipationData")
|
||||
.WithOpenApi();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetSession(HttpContext context, IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Ok(ToAuthSessionDto(session));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Ok(new { loggedOut = true });
|
||||
}
|
||||
|
||||
await userSessionService.LogoutAsync(session, context.RequestAborted);
|
||||
return Results.Ok(new { loggedOut = true });
|
||||
}
|
||||
|
||||
private static AuthSessionDto ToAuthSessionDto(UserSession session) =>
|
||||
new(
|
||||
session.SessionToken,
|
||||
session.TwitchUserId,
|
||||
session.DisplayName,
|
||||
session.Role);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> CreateClip(
|
||||
HttpContext context,
|
||||
CreateClipRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
{
|
||||
if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
|
||||
}
|
||||
|
||||
var platform = ResolveClipPlatform(clipUrl);
|
||||
if (platform is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||
if (clipSeasonResolution.Result is not null)
|
||||
{
|
||||
return clipSeasonResolution.Result;
|
||||
}
|
||||
|
||||
season = clipSeasonResolution.Season!;
|
||||
|
||||
var selectedCandidate = request.CandidateId is int candidateId
|
||||
? await db.Candidates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
|
||||
: null;
|
||||
if (request.CandidateId is not null && selectedCandidate is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
|
||||
}
|
||||
|
||||
if (request.CategoryId is int requestedCategoryId
|
||||
&& selectedCandidate is not null
|
||||
&& selectedCandidate.CategoryId != requestedCategoryId)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||
}
|
||||
|
||||
var resolvedCategoryId = request.CategoryId ?? selectedCandidate?.CategoryId;
|
||||
var normalizedTitle = request.Title?.Trim() ?? string.Empty;
|
||||
var submittedCreator = request.Creator?.Trim();
|
||||
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
|
||||
? selectedCandidate?.DisplayName ?? string.Empty
|
||||
: submittedCreator;
|
||||
if (normalizedTitle.Length > 160)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Clip titles must stay below 160 characters." });
|
||||
}
|
||||
|
||||
if (normalizedCreator.Length > 160)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Creator names must stay below 160 characters." });
|
||||
}
|
||||
|
||||
if (resolvedCategoryId is int categoryId)
|
||||
{
|
||||
var categoryExists = selectedCandidate?.CategoryId == categoryId
|
||||
|| await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
|
||||
if (!categoryExists)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||
}
|
||||
}
|
||||
|
||||
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
|
||||
if (submitterIdResult.Result is not null)
|
||||
{
|
||||
return submitterIdResult.Result;
|
||||
}
|
||||
|
||||
var submitterId = submitterIdResult.SubmitterId!;
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var duplicateClipRule = await riskRuleService.GetRuleAsync("duplicate_clip_submission", context.RequestAborted);
|
||||
var rapidClipBurstRule = await riskRuleService.GetRuleAsync("rapid_clip_burst", context.RequestAborted);
|
||||
var recentClipSubmissions = await db.ClipSubmissions.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidClipBurstRule.WindowMinutes));
|
||||
var alreadySubmittedClip = await db.ClipSubmissions.AnyAsync(item =>
|
||||
item.SeasonId == season.Id
|
||||
&& item.SubmittedByTwitchId == submitterId
|
||||
&& item.ClipUrl == clipUrl);
|
||||
|
||||
var clip = new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = resolvedCategoryId,
|
||||
CandidateId = selectedCandidate?.Id,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
ClipUrl = clipUrl,
|
||||
Title = normalizedTitle,
|
||||
Creator = normalizedCreator,
|
||||
Platform = platform,
|
||||
Status = "pending",
|
||||
CreatedFromIp = requestMetadata.ClientIp,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ClipSubmissions.Add(clip);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
|
||||
var clipLink = new
|
||||
{
|
||||
label = "Clip öffnen",
|
||||
entityType = "clip",
|
||||
entityId = clip.Id.ToString(),
|
||||
to = $"/admin/clips?query={Uri.EscapeDataString(clip.Id.ToString())}",
|
||||
};
|
||||
|
||||
if (alreadySubmittedClip && duplicateClipRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
season.Id,
|
||||
submitterId,
|
||||
"clip",
|
||||
"duplicate_clip_submission",
|
||||
duplicateClipRule.Severity,
|
||||
"Ein User hat denselben Clip erneut eingereicht.",
|
||||
requestMetadata,
|
||||
new { clipId = clip.Id, clipUrl, CategoryId = resolvedCategoryId, CandidateId = selectedCandidate?.Id, entityLinks = new[] { clipLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidClipBurstRule.Enabled && recentClipSubmissions >= rapidClipBurstRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
season.Id,
|
||||
submitterId,
|
||||
"clip",
|
||||
"rapid_clip_burst",
|
||||
rapidClipBurstRule.Severity,
|
||||
"Ungewoehnlich viele Clip-Einreichungen in kurzer Zeit erkannt.",
|
||||
requestMetadata,
|
||||
new { clipId = clip.Id, recentClipSubmissions, threshold = rapidClipBurstRule.Threshold, windowMinutes = rapidClipBurstRule.WindowMinutes, entityLinks = new[] { clipLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, clipId = clip.Id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Backend.Common;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private readonly record struct SubmitterIdResolution(string? SubmitterId, IResult? Result);
|
||||
private readonly record struct PublicWriteSeasonResolution(Season? Season, IResult? Result);
|
||||
private sealed record PublicCandidateClip(
|
||||
int? CategoryId,
|
||||
int? CandidateId,
|
||||
string Creator,
|
||||
string ClipUrl,
|
||||
string Title,
|
||||
string Platform,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
private static async Task<SubmitterIdResolution> ResolveSubmitterIdAsync(
|
||||
HttpContext context,
|
||||
string? fallbackTwitchUserId,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return new SubmitterIdResolution(
|
||||
null,
|
||||
Results.Json(
|
||||
new { message = "A logged in user is required to submit this action." },
|
||||
statusCode: StatusCodes.Status401Unauthorized));
|
||||
}
|
||||
|
||||
var submittedTwitchUserId = NormalizeSubmittedTwitchUserId(fallbackTwitchUserId);
|
||||
if (submittedTwitchUserId is not null
|
||||
&& !string.Equals(submittedTwitchUserId, session.TwitchUserId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new SubmitterIdResolution(
|
||||
null,
|
||||
Results.BadRequest(new { message = "Submitted user identity does not match the active session." }));
|
||||
}
|
||||
|
||||
return new SubmitterIdResolution(session.TwitchUserId, null);
|
||||
}
|
||||
|
||||
private static string? NormalizeSubmittedTwitchUserId(string? twitchUserId)
|
||||
{
|
||||
var normalized = twitchUserId?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
private static PublicWriteSeasonResolution EnsurePublicWriteSeason(
|
||||
Season? season,
|
||||
params string[] allowedPhaseKeys)
|
||||
{
|
||||
if (season is null)
|
||||
{
|
||||
return new PublicWriteSeasonResolution(null, Results.BadRequest(new { message = "The selected season does not exist." }));
|
||||
}
|
||||
|
||||
if (!season.IsCurrent)
|
||||
{
|
||||
return new PublicWriteSeasonResolution(
|
||||
null,
|
||||
Results.BadRequest(new { message = "Submissions are only allowed for the active season." }));
|
||||
}
|
||||
|
||||
var currentPhaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
if (!allowedPhaseKeys.Contains(currentPhaseKey, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var phaseLabel = DescribePublicPhase(currentPhaseKey);
|
||||
return new PublicWriteSeasonResolution(
|
||||
null,
|
||||
Results.BadRequest(new
|
||||
{
|
||||
message = $"This action is not available during the current season phase ({phaseLabel}).",
|
||||
}));
|
||||
}
|
||||
|
||||
return new PublicWriteSeasonResolution(season, null);
|
||||
}
|
||||
|
||||
private static bool TryNormalizeExternalUrl(string? rawUrl, out string normalizedUrl)
|
||||
{
|
||||
normalizedUrl = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedUrl = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ResolveClipPlatform(string clipUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var host = uri.Host.ToLowerInvariant();
|
||||
if (host is "twitch.tv" or "www.twitch.tv" or "clips.twitch.tv")
|
||||
{
|
||||
return "Twitch";
|
||||
}
|
||||
|
||||
if (host is "youtube.com" or "www.youtube.com" or "m.youtube.com" or "youtu.be")
|
||||
{
|
||||
return "YouTube";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ShouldExposePublicCategory(string phaseKey, int candidateCount) =>
|
||||
string.Equals(phaseKey, "nomination", StringComparison.OrdinalIgnoreCase) || candidateCount > 0;
|
||||
|
||||
private static Dictionary<int, PublicCandidateClip> BuildCandidateClipLookup(IEnumerable<PublicCandidateClip> clips) =>
|
||||
clips
|
||||
.Where(clip => clip.CandidateId is not null)
|
||||
.GroupBy(clip => clip.CandidateId!.Value)
|
||||
.ToDictionary(
|
||||
grouping => grouping.Key,
|
||||
grouping => grouping
|
||||
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
|
||||
.First());
|
||||
|
||||
private static Dictionary<string, PublicCandidateClip> BuildCreatorClipLookup(IEnumerable<PublicCandidateClip> clips) =>
|
||||
clips
|
||||
.Where(clip => clip.CategoryId is not null && !string.IsNullOrWhiteSpace(clip.Creator))
|
||||
.GroupBy(clip => BuildCandidateClipLookupKey(clip.CategoryId!.Value, clip.Creator))
|
||||
.Where(grouping => !string.IsNullOrWhiteSpace(grouping.Key))
|
||||
.ToDictionary(
|
||||
grouping => grouping.Key,
|
||||
grouping => grouping
|
||||
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
|
||||
.First());
|
||||
|
||||
private static PublicCandidateClip? ResolveCandidateClip(
|
||||
Candidate candidate,
|
||||
IReadOnlyDictionary<int, PublicCandidateClip> clipsByCandidateId,
|
||||
IReadOnlyDictionary<string, PublicCandidateClip> clipsByCreatorKey)
|
||||
{
|
||||
if (clipsByCandidateId.TryGetValue(candidate.Id, out var directClip))
|
||||
{
|
||||
return directClip;
|
||||
}
|
||||
|
||||
foreach (var key in BuildCandidateClipLookupKeys(candidate))
|
||||
{
|
||||
if (clipsByCreatorKey.TryGetValue(key, out var fallbackClip))
|
||||
{
|
||||
return fallbackClip;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildCandidateClipLookupKeys(Candidate candidate)
|
||||
{
|
||||
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.DisplayName);
|
||||
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug);
|
||||
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug.TrimStart('@'));
|
||||
}
|
||||
|
||||
private static string BuildCandidateClipLookupKey(int categoryId, string value)
|
||||
{
|
||||
var key = NormalizeCandidateClipKey(value);
|
||||
return string.IsNullOrWhiteSpace(key) ? string.Empty : $"{categoryId}:{key}";
|
||||
}
|
||||
|
||||
private static string NormalizeCandidateClipKey(string value)
|
||||
{
|
||||
var normalizedCharacters = value
|
||||
.Trim()
|
||||
.TrimStart('@')
|
||||
.ToLowerInvariant()
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.ToArray();
|
||||
|
||||
return new string(normalizedCharacters);
|
||||
}
|
||||
|
||||
private static string DescribePublicPhase(string phaseKey) =>
|
||||
phaseKey switch
|
||||
{
|
||||
"nomination" => "nomination",
|
||||
"voting" => "voting",
|
||||
"review" => "review",
|
||||
"show" => "show",
|
||||
_ => "current",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPublicEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/public");
|
||||
|
||||
group.MapGet("/overview", GetOverview)
|
||||
.WithName("GetOverview")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/site-status", GetSiteStatus)
|
||||
.WithName("GetSiteStatus")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
|
||||
.WithName("GetSeasonCategories")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{year:int}/winners", GetWinnerArchive)
|
||||
.WithName("GetWinnerArchive")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
||||
.WithName("GetUserParticipation")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/nominations", CreateNomination)
|
||||
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||
.WithName("CreateNomination")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/votes", CreateVote)
|
||||
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||
.WithName("CreateVote")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/clips", CreateClip)
|
||||
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||
.WithName("CreateClip")
|
||||
.WithOpenApi();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> CreateNomination(
|
||||
HttpContext context,
|
||||
CreateNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
{
|
||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
||||
|
||||
if (submittedNominations.Length is 0 or > 3)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.Name.Length > 120))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Nominee names must stay below 120 characters." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.StreamUrl.Length > 300))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Stream links must stay below 300 characters." });
|
||||
}
|
||||
|
||||
if (request.Nominations is { Length: > 0 } && submittedNominations.Any(item => string.IsNullOrWhiteSpace(item.StreamUrl)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A stream link is required for every nomination." });
|
||||
}
|
||||
|
||||
var distinctNomineeNames = submittedNominations
|
||||
.Select(item => item.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (distinctNomineeNames.Length != submittedNominations.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." });
|
||||
}
|
||||
|
||||
var invalidStreamUrl = submittedNominations
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.Select(item => item.StreamUrl)
|
||||
.FirstOrDefault(item => !TryNormalizeExternalUrl(item, out _));
|
||||
|
||||
if (invalidStreamUrl is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
|
||||
}
|
||||
|
||||
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 nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
|
||||
if (nominationSeasonResolution.Result is not null)
|
||||
{
|
||||
return nominationSeasonResolution.Result;
|
||||
}
|
||||
|
||||
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
|
||||
if (submitterIdResult.Result is not null)
|
||||
{
|
||||
return submitterIdResult.Result;
|
||||
}
|
||||
|
||||
var submitterId = submitterIdResult.SubmitterId!;
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
&& item.SubmittedByTwitchId == submitterId
|
||||
&& item.Status == "pending");
|
||||
|
||||
var records = submittedNominations.Select(nomination => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
CandidateText = nomination.Name,
|
||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||
Status = "pending",
|
||||
ReviewNote = string.IsNullOrWhiteSpace(nomination.StreamUrl)
|
||||
? null
|
||||
: $"Stream-Link: {nomination.StreamUrl}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}).ToArray();
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||
var rapidNominationBurstRule = await riskRuleService.GetRuleAsync("rapid_nomination_burst", context.RequestAborted);
|
||||
var recentNominationVolume = await db.Nominations.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidNominationBurstRule.WindowMinutes));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
var reviewLink = new
|
||||
{
|
||||
label = "Review-Fälle öffnen",
|
||||
entityType = "nomination",
|
||||
entityId = string.Join(",", records.Select(item => item.Id)),
|
||||
to = $"/admin/reviews?query={Uri.EscapeDataString(submitterId)}",
|
||||
};
|
||||
|
||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
resubmittedNominationRule.Severity,
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
requestMetadata,
|
||||
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
rapidNominationBurstRule.Severity,
|
||||
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
|
||||
requestMetadata,
|
||||
new { recentNominationVolume, threshold = rapidNominationBurstRule.Threshold, windowMinutes = rapidNominationBurstRule.WindowMinutes, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
||||
}
|
||||
|
||||
private readonly record struct SubmittedNomination(string Name, string StreamUrl);
|
||||
|
||||
private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request)
|
||||
{
|
||||
if (request.Nominations is { Length: > 0 })
|
||||
{
|
||||
return request.Nominations
|
||||
.Select(item =>
|
||||
{
|
||||
var name = item.Name?.Trim() ?? string.Empty;
|
||||
var streamUrl = item.StreamUrl?.Trim() ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
|
||||
{
|
||||
streamUrl = normalizedUrl;
|
||||
}
|
||||
|
||||
return new SubmittedNomination(name, streamUrl);
|
||||
})
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return (request.Nominees ?? [])
|
||||
.Select(item => new SubmittedNomination(item.Trim(), string.Empty))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetOverview(AwardsDbContext db)
|
||||
{
|
||||
var siteSettings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
|
||||
.ThenInclude(category => category.Candidates)
|
||||
.FirstOrDefaultAsync(item => item.IsCurrent);
|
||||
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (siteSettings is null)
|
||||
{
|
||||
return Results.Problem("Site settings are missing.");
|
||||
}
|
||||
|
||||
var winnerPreviewRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Season)
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.OrderByDescending(result => result.Season.Year)
|
||||
.ThenBy(result => result.CategoryName)
|
||||
.Take(8)
|
||||
.Select(result => new
|
||||
{
|
||||
Year = result.Season.Year,
|
||||
result.CategoryName,
|
||||
WinnerName = result.Candidate.DisplayName,
|
||||
WinnerSlug = result.Candidate.ChannelSlug,
|
||||
WinnerPlatform = result.Candidate.Platform,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
var winnerPreviewItems = winnerPreviewRows
|
||||
.Select(result => new WinnerPreviewDto(
|
||||
result.Year,
|
||||
result.CategoryName,
|
||||
result.WinnerName,
|
||||
result.WinnerSlug,
|
||||
result.WinnerPlatform,
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||
.ToArray();
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var response = new OverviewResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
season.Name,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
SeasonMappings.NormalizeSeasonStreamUrl(season.ShowStreamUrl),
|
||||
season.CurrentPhase,
|
||||
season.IsCommunityOnly,
|
||||
"Twitch",
|
||||
new[]
|
||||
{
|
||||
new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, SeasonMappings.ResolveTimelineState("nomination", phaseKey)),
|
||||
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, SeasonMappings.ResolveTimelineState("voting", phaseKey)),
|
||||
new TimelineItem("review", "Review & Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("review", phaseKey)),
|
||||
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||
},
|
||||
publicCategories
|
||||
.Select(category => new FeaturedCategoryDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser))
|
||||
.ToArray(),
|
||||
winnerPreviewItems,
|
||||
new PublicSiteContentDto(
|
||||
siteSettings.HostDisplayName,
|
||||
siteSettings.HostTagline,
|
||||
siteSettings.NewsletterUrl,
|
||||
siteSettings.PrivacyEmail,
|
||||
siteSettings.PrivacyPolicyContent,
|
||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||
SeasonMappings.BuildFooterLinks(siteSettings)),
|
||||
SeasonMappings.ReadFaqItems(siteSettings));
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetSeasonCategories(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();
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||
var approvedClips = await db.ClipSubmissions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.SeasonId == season.Id
|
||||
&& item.Status == "approved"
|
||||
&& item.CategoryId != null
|
||||
&& publicCategoryIds.Contains(item.CategoryId.Value))
|
||||
.Select(item => new PublicCandidateClip(
|
||||
item.CategoryId,
|
||||
item.CandidateId,
|
||||
item.Creator,
|
||||
item.ClipUrl,
|
||||
item.Title,
|
||||
item.Platform,
|
||||
item.CreatedAt,
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
var clipsByCandidateId = BuildCandidateClipLookup(approvedClips);
|
||||
var clipsByCreatorKey = BuildCreatorClipLookup(approvedClips);
|
||||
|
||||
return Results.Ok(new SeasonCategoriesResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
publicCategories.Select(category => new PublicCategoryDetailDto(
|
||||
category.Id,
|
||||
category.Name,
|
||||
category.GroupName,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser,
|
||||
category.Candidates.Select(candidate =>
|
||||
{
|
||||
var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey);
|
||||
return new CandidateSummaryDto(
|
||||
candidate.Id,
|
||||
candidate.DisplayName,
|
||||
candidate.ChannelSlug,
|
||||
candidate.Platform,
|
||||
clip?.ClipUrl,
|
||||
clip?.Title,
|
||||
clip?.Platform);
|
||||
}).ToArray()))
|
||||
.ToArray()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetSiteStatus(AwardsDbContext db, IConfiguration configuration)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.Ok(new PublicSiteStatusResponse(
|
||||
IsDemoLoginEnabled(configuration),
|
||||
false,
|
||||
"Sternenpause",
|
||||
"Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."));
|
||||
}
|
||||
|
||||
return Results.Ok(new PublicSiteStatusResponse(
|
||||
ResolveDemoLoginEnabled(settings, configuration),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? "Sternenpause" : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||
? "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."
|
||||
: settings.MaintenanceMessage));
|
||||
}
|
||||
|
||||
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||
{
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
if (!usesDatabaseDemo)
|
||||
{
|
||||
return IsDemoLoginEnabled(configuration);
|
||||
}
|
||||
|
||||
return settings.DemoLoginEnabled && HasDatabaseDemoCredentials(settings);
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
{
|
||||
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
|
||||
?? configuration["DemoAdmin:Enabled"];
|
||||
|
||||
return bool.TryParse(rawValue, out var enabled) && enabled;
|
||||
}
|
||||
|
||||
private static bool HasDatabaseDemoCredentials(Backend.Domain.SiteSettings settings) =>
|
||||
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetUserParticipation(
|
||||
HttpContext context,
|
||||
int year,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Year == year);
|
||||
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var nominations = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId)
|
||||
.OrderBy(item => item.CategoryId)
|
||||
.ThenBy(item => item.Id)
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.Status,
|
||||
Nominee = item.CandidateId != null
|
||||
? item.Candidate!.DisplayName
|
||||
: item.CandidateText,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
var groupedNominations = nominations
|
||||
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.Select(group => new UserNominationStateDto(
|
||||
group.Key,
|
||||
group.Select(item => item.Nominee!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()))
|
||||
.ToArray();
|
||||
|
||||
var votes = await db.VoteEntries
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Ballot.SeasonId == season.Id && item.Ballot.SubmittedByTwitchId == session.TwitchUserId)
|
||||
.OrderBy(item => item.CategoryId)
|
||||
.Select(item => new UserVoteStateDto(item.CategoryId, item.CandidateId))
|
||||
.ToArrayAsync();
|
||||
|
||||
var clips = await db.ClipSubmissions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(12)
|
||||
.Select(item => new UserClipSubmissionStateDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.ClipUrl,
|
||||
item.Title,
|
||||
item.Creator,
|
||||
item.Platform,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.ReviewNote,
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(new UserParticipationResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
groupedNominations,
|
||||
votes,
|
||||
clips));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> CreateVote(
|
||||
HttpContext context,
|
||||
CreateVoteRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
{
|
||||
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 season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == request.SeasonId);
|
||||
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
|
||||
if (voteSeasonResolution.Result is not null)
|
||||
{
|
||||
return voteSeasonResolution.Result;
|
||||
}
|
||||
|
||||
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
|
||||
if (submitterIdResult.Result is not null)
|
||||
{
|
||||
return submitterIdResult.Result;
|
||||
}
|
||||
|
||||
var submitterId = submitterIdResult.SubmitterId!;
|
||||
var requestMetadata = RequestMetadataReader.Read(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 resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
|
||||
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
|
||||
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
var ballotLink = new
|
||||
{
|
||||
label = "Voting-Analytics öffnen",
|
||||
entityType = "vote",
|
||||
entityId = ballot.Id.ToString(),
|
||||
to = $"/admin/analytics?query={Uri.EscapeDataString(submitterId)}",
|
||||
};
|
||||
|
||||
if (isResubmission && resubmittedBallotRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
request.SeasonId,
|
||||
submitterId,
|
||||
"vote",
|
||||
"resubmitted_ballot",
|
||||
resubmittedBallotRule.Severity,
|
||||
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
|
||||
requestMetadata,
|
||||
new { ballotId = ballot.Id, entryCount = request.Entries.Length, entityLinks = new[] { ballotLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
request.SeasonId,
|
||||
submitterId,
|
||||
"vote",
|
||||
"rapid_vote_updates",
|
||||
rapidVoteUpdatesRule.Severity,
|
||||
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
|
||||
requestMetadata,
|
||||
new { ballotId = ballot.Id, recentVoteSubmissions, threshold = rapidVoteUpdatesRule.Threshold, windowMinutes = rapidVoteUpdatesRule.WindowMinutes, entityLinks = new[] { ballotLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
|
||||
{
|
||||
var winnerRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year == year)
|
||||
.OrderBy(result => result.CategoryName)
|
||||
.Select(result => new
|
||||
{
|
||||
result.CategoryName,
|
||||
WinnerName = result.Candidate.DisplayName,
|
||||
WinnerSlug = result.Candidate.ChannelSlug,
|
||||
WinnerPlatform = result.Candidate.Platform,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
var items = winnerRows
|
||||
.Select(result => new WinnerArchiveItemDto(
|
||||
result.CategoryName,
|
||||
result.WinnerName,
|
||||
result.WinnerSlug,
|
||||
result.WinnerPlatform,
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new WinnerArchiveResponse(year, items));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static class SystemEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSystemEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" }))
|
||||
.WithName("GetHealth")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapGet("/api/health/database", async (AwardsDbContext db, IConfiguration configuration) =>
|
||||
{
|
||||
var source = configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings";
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
provider = "postgres",
|
||||
canConnect = false,
|
||||
pendingMigrations = Array.Empty<string>(),
|
||||
configuredConnection = new { source },
|
||||
error = exception.Message,
|
||||
});
|
||||
}
|
||||
})
|
||||
.WithName("GetDatabaseHealth")
|
||||
.WithOpenApi();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user