Harden request validation and admin UX cues
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 58s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m24s

This commit is contained in:
AzuTear
2026-06-30 08:45:28 +02:00
parent 1191abc914
commit 33ba00a682
11 changed files with 248 additions and 70 deletions
+7 -2
View File
@@ -151,10 +151,15 @@ public static partial class AdminModerationEndpoints
private static async Task<IResult> BulkResolveRiskFlags(
HttpContext context,
BulkResolveRiskFlagsRequest request,
BulkResolveRiskFlagsRequest? request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
if (request is null || request.RiskFlagIds is null)
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht mindestens einen Risikohinweis." });
}
var session = AdminEndpointConventions.CurrentSession(context);
var normalizedIds = request.RiskFlagIds
.Distinct()
@@ -188,7 +193,7 @@ public static partial class AdminModerationEndpoints
if (riskFlags.Any(item => item.Severity != "low"))
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer Low-Severity-Hinweise erlaubt." });
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Low-Severity-Hinweise erlaubt." });
}
if (riskFlags.Any(item => item.Status != "open"))
+87 -28
View File
@@ -155,10 +155,17 @@ public static class AdminSiteSettingsEndpoints
private static async Task<IResult> UpdateSiteSettings(
HttpContext context,
UpdateSiteSettingsRequest request,
UpdateSiteSettingsRequest? request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var requestValidationError = ValidateSiteSettingsRequest(request);
if (requestValidationError is not null)
{
return requestValidationError;
}
var validatedRequest = request!;
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
@@ -167,20 +174,20 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks);
var urlValidationError = NormalizeSiteSettingsUrls(validatedRequest, out var normalizedUrls, out var socialLinks);
if (urlValidationError is not null)
{
return urlValidationError;
}
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.HostArtistName = request.HostArtistName.Trim();
settings.HostDisplayName = validatedRequest.HostDisplayName.Trim();
settings.HostTagline = validatedRequest.HostTagline.Trim();
settings.HostArtistName = validatedRequest.HostArtistName.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
settings.PrivacyEmail = validatedRequest.PrivacyEmail.Trim();
var trimmedPrivacyContent = validatedRequest.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
settings.PrivacyPolicyContent = trimmedPrivacyContent;
if (privacyChanged)
@@ -190,32 +197,32 @@ public static class AdminSiteSettingsEndpoints
}
settings.ImprintUrl = normalizedUrls.ImprintUrl;
settings.ImprintContent = request.ImprintContent.Trim();
settings.ImprintContent = validatedRequest.ImprintContent.Trim();
settings.ContactUrl = normalizedUrls.ContactUrl;
settings.ContactContent = request.ContactContent.Trim();
settings.ContactContent = validatedRequest.ContactContent.Trim();
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.SponsorsContent = validatedRequest.SponsorsContent.Trim();
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
settings.ShowactsContent = request.ShowactsContent.Trim();
settings.StreamBannerEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerEyebrow);
settings.StreamBannerTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerTitle);
settings.StreamBannerText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerText);
settings.StreamBannerLiveButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLiveButtonLabel);
settings.ShowactsContent = validatedRequest.ShowactsContent.Trim();
settings.StreamBannerEyebrow = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerEyebrow);
settings.StreamBannerTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerTitle);
settings.StreamBannerText = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerText);
settings.StreamBannerLiveButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerLiveButtonLabel);
settings.StreamBannerLiveButtonUrl = normalizedUrls.StreamBannerLiveButtonUrl;
settings.StreamBannerLockedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLockedButtonLabel);
settings.StreamBannerUseCompletedContent = request.StreamBannerUseCompletedContent;
settings.StreamBannerCompletedEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedEyebrow);
settings.StreamBannerCompletedTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedTitle);
settings.StreamBannerCompletedText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedText);
settings.StreamBannerCompletedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedButtonLabel);
settings.StreamBannerLockedButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerLockedButtonLabel);
settings.StreamBannerUseCompletedContent = validatedRequest.StreamBannerUseCompletedContent;
settings.StreamBannerCompletedEyebrow = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedEyebrow);
settings.StreamBannerCompletedTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedTitle);
settings.StreamBannerCompletedText = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedText);
settings.StreamBannerCompletedButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedButtonLabel);
settings.StreamBannerCompletedButtonUrl = normalizedUrls.StreamBannerCompletedButtonUrl;
settings.AwardsSectionTitle = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionTitle);
settings.AwardsSectionDescription = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionDescription);
settings.SubcategoriesSectionTitle = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionTitle);
settings.SubcategoriesSectionDescription = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionDescription);
settings.AwardsSectionTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.AwardsSectionTitle);
settings.AwardsSectionDescription = SeasonMappings.NormalizePlainTextContent(validatedRequest.AwardsSectionDescription);
settings.SubcategoriesSectionTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.SubcategoriesSectionTitle);
settings.SubcategoriesSectionDescription = SeasonMappings.NormalizePlainTextContent(validatedRequest.SubcategoriesSectionDescription);
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
settings.FaqJson = JsonSerializer.Serialize(validatedRequest.Faq);
settings.ShowactFormSchemaJson = validatedRequest.ShowactFormSchemaJson ?? "[]";
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -228,7 +235,7 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName,
privacyChanged,
socialLinkCount = socialLinks.Length,
faqCount = request.Faq?.Length ?? 0,
faqCount = validatedRequest.Faq.Length,
},
RequestMetadataReader.Read(context));
@@ -340,6 +347,58 @@ public static class AdminSiteSettingsEndpoints
return null;
}
private static IResult? ValidateSiteSettingsRequest(UpdateSiteSettingsRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "Public Site Settings brauchen einen Request-Body." });
}
if (request.SocialLinks is null || request.Faq is null)
{
return Results.BadRequest(new { message = "Public Site Settings brauchen Social Links und FAQ im Request-Body." });
}
string?[] requiredTextFields =
[
request.HostDisplayName,
request.HostTagline,
request.HostArtistName,
request.NewsletterUrl,
request.ShareXUrl,
request.ShareDiscordUrl,
request.PrivacyEmail,
request.PrivacyPolicyContent,
request.ImprintUrl,
request.ImprintContent,
request.ContactUrl,
request.ContactContent,
request.SponsorsUrl,
request.SponsorsContent,
request.ShowactsUrl,
request.ShowactsContent,
request.StreamBannerEyebrow,
request.StreamBannerTitle,
request.StreamBannerText,
request.StreamBannerLiveButtonLabel,
request.StreamBannerLiveButtonUrl,
request.StreamBannerLockedButtonLabel,
request.StreamBannerCompletedEyebrow,
request.StreamBannerCompletedTitle,
request.StreamBannerCompletedText,
request.StreamBannerCompletedButtonLabel,
request.StreamBannerCompletedButtonUrl,
request.AwardsSectionTitle,
request.AwardsSectionDescription,
request.SubcategoriesSectionTitle,
request.SubcategoriesSectionDescription,
];
return requiredTextFields.Any(field => field is null)
? Results.BadRequest(new { message = "Public Site Settings brauchen alle Pflichtfelder im Request-Body." })
: null;
}
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
{
normalizedUrl = (value ?? string.Empty).Trim();
+10 -5
View File
@@ -15,16 +15,21 @@ public static partial class AuthEndpoints
private static async Task<IResult> TeamLogin(
HttpContext context,
TeamLoginRequest request,
TeamLoginRequest? request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var login = NormalizeTeamLogin(request.Login);
if (request is null)
{
return Results.BadRequest(new { message = "Login und Passwort sind erforderlich." });
}
var login = NormalizeTeamLogin(request.Login ?? string.Empty);
var password = request.Password ?? string.Empty;
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
{
return Results.Unauthorized();
return Results.BadRequest(new { message = "Login und Passwort sind erforderlich." });
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
@@ -120,8 +125,8 @@ public static partial class AuthEndpoints
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
}
private static string NormalizeTeamLogin(string value) =>
value.Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTeamLogin(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTwitchUserId(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
+17 -10
View File
@@ -11,13 +11,20 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> CreateClip(
HttpContext context,
CreateClipRequest request,
CreateClipRequest? request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl))
var validationError = ValidateCreateClipRequest(request);
if (validationError is not null)
{
return validationError;
}
var validatedRequest = request!;
if (!TryNormalizeExternalUrl(validatedRequest.ClipUrl, out var clipUrl))
{
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
}
@@ -44,7 +51,7 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == validatedRequest.Year);
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
if (clipSeasonResolution.Result is not null)
{
@@ -53,26 +60,26 @@ public static partial class PublicEndpoints
season = clipSeasonResolution.Season!;
var selectedCandidate = request.CandidateId is int candidateId
var selectedCandidate = validatedRequest.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)
if (validatedRequest.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
if (validatedRequest.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 resolvedCategoryId = validatedRequest.CategoryId ?? selectedCandidate?.CategoryId;
var normalizedTitle = validatedRequest.Title?.Trim() ?? string.Empty;
var submittedCreator = validatedRequest.Creator?.Trim();
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
? selectedCandidate?.DisplayName ?? string.Empty
: submittedCreator;
@@ -96,7 +103,7 @@ public static partial class PublicEndpoints
}
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
@@ -1,4 +1,5 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Domain;
using Backend.Services;
using Microsoft.AspNetCore.Http.Extensions;
@@ -205,4 +206,74 @@ public static partial class PublicEndpoints
"show" => "show",
_ => "current",
};
private static IResult? ValidateCreateNominationRequest(CreateNominationRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "A nomination request body is required." });
}
if (request.Year <= 0)
{
return Results.BadRequest(new { message = "A valid season year is required." });
}
if ((request.Nominations?.Length ?? 0) == 0 && (request.Nominees?.Length ?? 0) == 0)
{
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
}
return null;
}
private static IResult? ValidateCreateVoteRequest(CreateVoteRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "A vote request body is required." });
}
if (request.SeasonId <= 0)
{
return Results.BadRequest(new { message = "A valid season id is required." });
}
if (request.Entries is null || request.Entries.Length == 0)
{
return Results.BadRequest(new { message = "At least one vote entry is required." });
}
if (request.Entries.Any(item => item.CategoryId <= 0 || item.CandidateId <= 0))
{
return Results.BadRequest(new { message = "Each vote entry requires a valid category and candidate." });
}
return null;
}
private static IResult? ValidateCreateClipRequest(CreateClipRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "A clip request body is required." });
}
if (request.Year <= 0)
{
return Results.BadRequest(new { message = "A valid season year is required." });
}
if (request.CategoryId is <= 0 || request.CandidateId is <= 0)
{
return Results.BadRequest(new { message = "Category and candidate ids must be positive when provided." });
}
if (string.IsNullOrWhiteSpace(request.ClipUrl))
{
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
}
return null;
}
}
@@ -21,7 +21,7 @@ public static partial class PublicEndpoints
if (settings?.HostImageData is not { Length: > 0 } imageData
|| string.IsNullOrWhiteSpace(settings.HostImageContentType))
{
return Results.NotFound();
return Results.Redirect("/assets/amaterasu2sei_2.png", permanent: false);
}
var entityTag = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
+12 -5
View File
@@ -11,14 +11,21 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> CreateNomination(
HttpContext context,
CreateNominationRequest request,
CreateNominationRequest? request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService,
NominationEnrichmentService nominationEnrichmentService)
{
var submittedNominations = NormalizeSubmittedNominations(request);
var validationError = ValidateCreateNominationRequest(request);
if (validationError is not null)
{
return validationError;
}
var validatedRequest = request!;
var submittedNominations = NormalizeSubmittedNominations(validatedRequest);
if (submittedNominations.Length == 0)
{
@@ -66,14 +73,14 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == validatedRequest.Year);
if (season is null)
{
return Results.BadRequest(new { message = "The selected season does not exist." });
}
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted);
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, validatedRequest, context.RequestAborted);
if (string.IsNullOrWhiteSpace(categoryGroupName))
{
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
@@ -102,7 +109,7 @@ public static partial class PublicEndpoints
return nominationSeasonResolution.Result;
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
+19 -17
View File
@@ -12,37 +12,39 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> CreateVote(
HttpContext context,
CreateVoteRequest request,
CreateVoteRequest? request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
if (request.Entries.Length == 0)
var validationError = ValidateCreateVoteRequest(request);
if (validationError is not null)
{
return Results.BadRequest(new { message = "At least one vote entry is required." });
return validationError;
}
var distinctCategoryCount = request.Entries
var validatedRequest = request!;
var distinctCategoryCount = validatedRequest.Entries
.Select(item => item.CategoryId)
.Distinct()
.Count();
if (distinctCategoryCount != request.Entries.Length)
if (distinctCategoryCount != validatedRequest.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);
.FirstOrDefaultAsync(item => item.Id == validatedRequest.SeasonId);
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
if (voteSeasonResolution.Result is not null)
{
return voteSeasonResolution.Result;
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
@@ -50,10 +52,10 @@ public static partial class PublicEndpoints
var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context);
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
var candidateIds = validatedRequest.Entries.Select(item => item.CandidateId).Distinct().ToArray();
var validCandidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id))
.Where(item => item.SeasonId == validatedRequest.SeasonId && candidateIds.Contains(item.Id))
.Select(item => new { item.Id, item.CategoryId })
.ToArrayAsync();
@@ -63,17 +65,17 @@ public static partial class PublicEndpoints
}
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
if (validatedRequest.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);
.FirstOrDefaultAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null;
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
@@ -90,9 +92,9 @@ public static partial class PublicEndpoints
db.ChangeTracker.Clear();
ballot = await db.VoteBallots
.Include(item => item.Entries)
.FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
.FirstAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
isResubmission = true;
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
}
@@ -107,21 +109,21 @@ public static partial class PublicEndpoints
if (isResubmission && resubmittedBallotRule.Enabled)
{
await riskFlagService.AddIfMissingAsync(
request.SeasonId,
validatedRequest.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 } },
new { ballotId = ballot.Id, entryCount = validatedRequest.Entries.Length, entityLinks = new[] { ballotLink } },
context.RequestAborted);
}
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
request.SeasonId,
validatedRequest.SeasonId,
submitterId,
"vote",
"rapid_vote_updates",
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { KeyRound, Save, Wrench } from '@lucide/vue'
import { KeyRound, LockKeyhole, Save, Wrench } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
@@ -65,6 +65,14 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
</Button>
</div>
<p
v-if="!canManageOperationalSettings"
class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold leading-6 text-slate-700"
>
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
Demo-Schutz und Wartungsmodus sind sichtbar, aber nur Owner oder Creator dürfen diese Einstellungen speichern.
</p>
<section class="grid gap-6 xl:grid-cols-2">
<div class="space-y-4">
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-violet-700">
@@ -208,6 +208,9 @@ function statToneClass(tone: string) {
<!-- Flag list -->
<div class="flex-1 divide-y divide-violet-50 overflow-y-auto xl:max-h-[640px]">
<div class="border-b border-amber-100 bg-amber-50/70 px-5 py-3 text-xs font-semibold leading-5 text-amber-800">
Bulk-Aktionen greifen nur für offene Low-Severity-Hinweise. Medium- und High-Fälle bleiben absichtlich Einzelentscheidungen.
</div>
<button
v-for="flag in filteredRiskFlags"
:key="flag.id"
@@ -336,6 +339,9 @@ function statToneClass(tone: string) {
class="mb-3 h-10 w-full rounded-xl border border-amber-200 bg-white px-3 text-sm outline-none transition focus:border-amber-400 focus:ring-4 focus:ring-amber-100/60"
placeholder="Bulk-Notiz eingeben (mind. 3 Zeichen)"
/>
<p class="mb-3 text-xs font-semibold text-amber-800">
Die Auswahl bleibt auf offene Low-Hinweise beschränkt. Höhere Severity wird bewusst nicht in Bulk verarbeitet.
</p>
<div class="flex gap-2">
<Button
size="sm"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Save, Settings } from '@lucide/vue'
import { LockKeyhole, Save, Settings } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
@@ -87,6 +87,14 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
</Button>
</div>
<p
v-if="!canManageOperationalSettings"
class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold leading-6 text-slate-700"
>
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
Die Seite bleibt für Admins sichtbar, aber Demo-, OAuth- und Session-Betriebseinstellungen dürfen nur Owner oder Creator speichern.
</p>
<AdminSettingsOverviewBoard
:checks="checks"
:gates="gates"