Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
@@ -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.");
}
+36
View File
@@ -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 });
}
}
+382
View File
@@ -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,
};
}
}
+124
View File
@@ -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,
});
}
}
+182
View File
@@ -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));
}
}
+37
View File
@@ -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;
}
}
+38
View File
@@ -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);
}
+158
View File
@@ -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 });
}
}
+208
View File
@@ -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",
};
}
+46
View File
@@ -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));
}
}
+145
View File
@@ -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));
}
}
+50
View File
@@ -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;
}
}