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."; private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen."; private const string DefaultHostImageUrl = "/assets/amaterasu2sei_2.png"; private const int MaxHostImageBytes = 8 * 1024 * 1024; private static readonly IReadOnlyDictionary AllowedHostImageContentTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["image/png"] = ".png", ["image/jpeg"] = ".jpg", ["image/webp"] = ".webp", }; public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group) { group.MapGet("/site-settings", GetSiteSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Content)) .WithName("GetAdminSiteSettings") .WithOpenApi(); group.MapPut("/site-settings", UpdateSiteSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content)) .WithName("UpdateAdminSiteSettings") .WithOpenApi(); group.MapPost("/site-settings/host-image", UploadHostImage) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content)) .WithName("UploadAdminHostImage") .WithOpenApi(); group.MapGet("/operational-settings", GetOperationalSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("GetAdminOperationalSettings") .WithOpenApi(); group.MapPut("/operational-settings", UpdateOperationalSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminOperationalSettings") .WithOpenApi(); group.MapGet("/optional-feature-settings", GetOptionalFeatureSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("GetAdminOptionalFeatureSettings") .WithOpenApi(); group.MapPut("/optional-feature-settings", UpdateOptionalFeatureSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminOptionalFeatureSettings") .WithOpenApi(); group.MapGet("/tracking-rules", GetTrackingRules) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("GetAdminTrackingRules") .WithOpenApi(); group.MapPut("/tracking-rules", UpdateTrackingRules) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminTrackingRules") .WithOpenApi(); group.MapPut("/tracking-rules/source", UpdateTrackingSource) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminTrackingSource") .WithOpenApi(); group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminTrackingReviewNotes") .WithOpenApi(); return group; } private static async Task GetSiteSettings(AwardsDbContext db) { var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } return Results.Ok(MapSiteSettingsResponse(settings)); } private static async Task UploadHostImage( HttpContext context, AwardsDbContext db, IAdminAuditService adminAuditService) { if (!context.Request.HasFormContentType) { return Results.BadRequest(new { message = "Bitte ein Bild als Formular-Upload senden." }); } var form = await context.Request.ReadFormAsync(context.RequestAborted); var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault(); if (file is null || file.Length == 0) { return Results.BadRequest(new { message = "Bitte ein Hostbild auswählen." }); } if (file.Length > MaxHostImageBytes) { return Results.BadRequest(new { message = "Hostbild ist zu groß. Maximal erlaubt sind 8 MB." }); } if (!AllowedHostImageContentTypes.TryGetValue(file.ContentType, out var expectedExtension)) { return Results.BadRequest(new { message = "Bitte PNG, JPG oder WebP hochladen." }); } var extension = Path.GetExtension(file.FileName); if (!string.IsNullOrWhiteSpace(extension) && !string.Equals(extension, expectedExtension, StringComparison.OrdinalIgnoreCase) && !(string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase) && string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))) { return Results.BadRequest(new { message = "Dateiendung und Bildtyp passen nicht zusammen." }); } var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } await using var stream = file.OpenReadStream(); using var memory = new MemoryStream((int)file.Length); await stream.CopyToAsync(memory, context.RequestAborted); settings.HostImageData = memory.ToArray(); settings.HostImageContentType = file.ContentType; settings.HostImageUpdatedAt = DateTimeOffset.UtcNow; var session = AdminEndpointConventions.CurrentSession(context); adminAuditService.AddEntry( session.TwitchUserId, "site-settings.host-image.upload", "site-settings", settings.Id.ToString(), "Landingpage-Hostbild wurde aktualisiert.", new { fileName = file.FileName, fileSize = file.Length, file.ContentType, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(MapSiteSettingsResponse(settings)); } private static async Task UpdateSiteSettings( HttpContext context, 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); if (settings is null) { return Results.NotFound(); } var urlValidationError = NormalizeSiteSettingsUrls(validatedRequest, out var normalizedUrls, out var socialLinks); if (urlValidationError is not null) { return urlValidationError; } 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 = validatedRequest.PrivacyEmail.Trim(); var trimmedPrivacyContent = validatedRequest.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 = normalizedUrls.ImprintUrl; settings.ImprintContent = validatedRequest.ImprintContent.Trim(); settings.ContactUrl = normalizedUrls.ContactUrl; settings.ContactContent = validatedRequest.ContactContent.Trim(); settings.SponsorsUrl = normalizedUrls.SponsorsUrl; settings.SponsorsContent = validatedRequest.SponsorsContent.Trim(); settings.ShowactsUrl = normalizedUrls.ShowactsUrl; 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(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(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(validatedRequest.Faq); settings.ShowactFormSchemaJson = validatedRequest.ShowactFormSchemaJson ?? "[]"; adminAuditService.AddEntry( session.TwitchUserId, "site-settings.update", "site-settings", settings.Id.ToString(), "Public Site Settings wurden aktualisiert.", new { settings.HostDisplayName, privacyChanged, socialLinkCount = socialLinks.Length, faqCount = validatedRequest.Faq.Length, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true }); } private static AdminSiteSettingsResponse MapSiteSettingsResponse(SiteSettings settings) { return new AdminSiteSettingsResponse( settings.HostDisplayName, settings.HostTagline, settings.HostArtistName, BuildHostImageUrl(settings), settings.NewsletterUrl, settings.ShareXUrl, settings.ShareDiscordUrl, settings.PrivacyEmail, settings.PrivacyPolicyContent, settings.PrivacyPolicyUpdatedBy, settings.PrivacyPolicyUpdatedAt, settings.ImprintUrl, settings.ImprintContent, settings.ContactUrl, settings.ContactContent, settings.SponsorsUrl, settings.SponsorsContent, settings.ShowactsUrl, settings.ShowactsContent, SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow), SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle), SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText), SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel), settings.StreamBannerLiveButtonUrl, SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel), settings.StreamBannerUseCompletedContent, SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow), SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle), SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText), SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel), settings.StreamBannerCompletedButtonUrl, SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle), SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription), SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle), SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription), SeasonMappings.ReadSocialLinks(settings), SeasonMappings.ReadFaqItems(settings), settings.ShowactFormSchemaJson ?? "[]"); } internal static string BuildHostImageUrl(SiteSettings settings) { if (settings.HostImageData is not { Length: > 0 }) { return DefaultHostImageUrl; } var version = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) ?? settings.HostImageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture); return $"/api/public/host-image?v={version}"; } private static IResult? NormalizeSiteSettingsUrls( UpdateSiteSettingsRequest request, out PublicSiteUrlSettings normalizedUrls, out PublicSocialLinkDto[] socialLinks) { normalizedUrls = new PublicSiteUrlSettings(); socialLinks = []; if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage) || !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage) || !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out errorMessage) || !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage) || !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage) || !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage) || !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage) || !TryNormalizePublicUrl(request.StreamBannerLiveButtonUrl, "Finale-Banner Button-Link", out var streamBannerLiveButtonUrl, out errorMessage) || !TryNormalizePublicUrl(request.StreamBannerCompletedButtonUrl, "Finale-Banner Abschluss-Link", out var streamBannerCompletedButtonUrl, out errorMessage)) { return Results.BadRequest(new { message = errorMessage }); } normalizedUrls = new PublicSiteUrlSettings { NewsletterUrl = newsletterUrl, ShareXUrl = shareXUrl, ShareDiscordUrl = shareDiscordUrl, ImprintUrl = imprintUrl, ContactUrl = contactUrl, SponsorsUrl = sponsorsUrl, ShowactsUrl = showactsUrl, StreamBannerLiveButtonUrl = streamBannerLiveButtonUrl, StreamBannerCompletedButtonUrl = streamBannerCompletedButtonUrl, }; var normalizedSocialLinks = new List(); foreach (var social in request.SocialLinks ?? []) { if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage)) { return Results.BadRequest(new { message = errorMessage }); } normalizedSocialLinks.Add(social with { Url = normalizedUrl }); } socialLinks = normalizedSocialLinks.ToArray(); 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(); errorMessage = string.Empty; if (string.IsNullOrWhiteSpace(normalizedUrl)) { return true; } if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { errorMessage = $"{fieldName} muss eine absolute http/https-URL sein."; return false; } normalizedUrl = uri.ToString(); return true; } private sealed class PublicSiteUrlSettings { public string NewsletterUrl { get; set; } = string.Empty; public string ShareXUrl { get; set; } = string.Empty; public string ShareDiscordUrl { get; set; } = string.Empty; public string ImprintUrl { get; set; } = string.Empty; public string ContactUrl { get; set; } = string.Empty; public string SponsorsUrl { get; set; } = string.Empty; public string ShowactsUrl { get; set; } = string.Empty; public string StreamBannerLiveButtonUrl { get; set; } = string.Empty; public string StreamBannerCompletedButtonUrl { get; set; } = string.Empty; } private static async Task GetOptionalFeatureSettings(AwardsDbContext db) { var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } return Results.Ok(ToOptionalFeatureSettingsResponse(settings)); } private static async Task GetTrackingRules(AwardsDbContext db) { var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } return Results.Ok(ToTrackingRulesResponse(settings)); } private static async Task UpdateTrackingRules( HttpContext context, UpdateTrackingRulesRequest request, AwardsDbContext db, IAdminAuditService adminAuditService, NominationTrackingReviewService trackingReviewService) { var session = AdminEndpointConventions.CurrentSession(context); var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } var before = ToTrackingRulesResponse(settings); var applyResult = ApplyTrackingRulesRequest(settings, request); if (applyResult is not null) { return applyResult; } await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted); var after = ToTrackingRulesResponse(settings); adminAuditService.AddEntry( session.TwitchUserId, "tracking-rules.update", "site-settings", settings.Id.ToString(), "Tracking Rules wurden aktualisiert.", new { sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl, manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes, importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled), optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled), flagCount = after.Flags.Count(item => item.Enabled), }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(after); } private static async Task UpdateTrackingSource( HttpContext context, UpdateTrackingSourceRequest request, AwardsDbContext db, IAdminAuditService adminAuditService, NominationTrackingReviewService trackingReviewService) { var session = AdminEndpointConventions.CurrentSession(context); var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage)) { return Results.BadRequest(new { message = errorMessage }); } var before = ToTrackingRulesResponse(settings); var rules = TrackingRulesSettings.Read(settings); var updatedRules = rules with { Source = new TrackingSourceSetting( TrackingRulesSettings.ProviderKey, normalizedBaseUrl, request.Source?.NotesSummary ?? rules.Source.NotesSummary, request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview), }; settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl; settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules); await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted); var after = ToTrackingRulesResponse(settings); adminAuditService.AddEntry( session.TwitchUserId, "tracking-source.update", "site-settings", settings.Id.ToString(), "Tracking Source wurde aktualisiert.", new { beforeBaseUrl = before.Source.BaseUrl, afterBaseUrl = after.Source.BaseUrl, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(after); } private static async Task UpdateTrackingReviewNotes( HttpContext context, UpdateTrackingReviewNotesRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } var beforeNotes = settings.TrackingReviewNotes ?? string.Empty; var before = ToTrackingRulesResponse(settings); var rules = TrackingRulesSettings.Read(settings); settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim(); settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with { Source = rules.Source with { ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview, }, }); var after = ToTrackingRulesResponse(settings); adminAuditService.AddEntry( session.TwitchUserId, "tracking-review-notes.update", "site-settings", settings.Id.ToString(), "Tracking Review Notes wurden aktualisiert.", new { beforeLength = beforeNotes.Length, afterLength = settings.TrackingReviewNotes.Length, beforeShowInReview = before.Source.ShowManualReviewNotesInReview, afterShowInReview = after.Source.ShowManualReviewNotesInReview, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(after); } private static async Task UpdateOptionalFeatureSettings( HttpContext context, UpdateOptionalFeatureSettingsRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } var before = ToOptionalFeatureSettingsResponse(settings); var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt); if (scheduleValidationError is not null) { return Results.BadRequest(new { message = scheduleValidationError }); } var disabledMessage = NormalizeOptionalFeatureText( request.ClipSubmissionDisabledMessage, FallbackClipSubmissionDisabledMessage, 240); var showactDisabledMessage = NormalizeOptionalFeatureText( request.ShowactApplicationDisabledMessage, "Showact-Bewerbungen sind aktuell geschlossen.", 240); settings.ClipSubmissionsEnabled = request.ClipSubmissionsEnabled; settings.ClipReviewEnabled = request.ClipReviewEnabled; settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible; settings.ClipSubmissionDisabledMessage = disabledMessage; settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled; settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt; settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt; settings.ShowactApplicationDisabledMessage = showactDisabledMessage; settings.SponsorsVisible = request.SponsorsVisible; var after = ToOptionalFeatureSettingsResponse(settings); adminAuditService.AddEntry( session.TwitchUserId, "optional-features.update", "site-settings", settings.Id.ToString(), "Optionale Workflow-Features wurden aktualisiert.", new { before, after, changes = BuildOptionalFeatureChanges(before, after), }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(after); } private static AdminOptionalFeatureSettingsResponse ToOptionalFeatureSettingsResponse(SiteSettings settings) => new( settings.ClipSubmissionsEnabled, settings.ClipReviewEnabled, settings.ClipAdminMenuVisible, string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage) ? FallbackClipSubmissionDisabledMessage : settings.ClipSubmissionDisabledMessage, settings.ShowactApplicationsEnabled, settings.ShowactApplicationStartsAt, settings.ShowactApplicationEndsAt, ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)), string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage) ? "Showact-Bewerbungen sind aktuell geschlossen." : settings.ShowactApplicationDisabledMessage, settings.SponsorsVisible); private static string NormalizeOptionalFeatureText(string? value, string fallback, int maxLength) { var trimmed = (value ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(trimmed)) { return fallback; } return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; } private static object[] BuildOptionalFeatureChanges( AdminOptionalFeatureSettingsResponse before, AdminOptionalFeatureSettingsResponse after) { var changes = new List(); AddOperationalChange(changes, "clipSubmissionsEnabled", "Clip-Einreichung", before.ClipSubmissionsEnabled, after.ClipSubmissionsEnabled); AddOperationalChange(changes, "clipReviewEnabled", "Clip-Review", before.ClipReviewEnabled, after.ClipReviewEnabled); AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible); AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage); AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled); AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt); AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt); AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage); AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible); return changes.ToArray(); } private static async Task 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; var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration); 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.TwitchAuthManagedByDatabase, twitchSettings.Configured, twitchSettings.ClientId, twitchSettings.ClientSecretSet, twitchSettings.RedirectUri, twitchSettings.Scope, UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours), settings.MaintenanceModeEnabled, string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage)); } private static async Task 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, configuration); 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"); var twitchClientId = request.TwitchClientId.Trim(); var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty; var twitchRedirectUri = request.TwitchRedirectUri.Trim(); var twitchScope = request.TwitchScope.Trim(); if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours) { return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." }); } var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours); var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret) || !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET")); 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 < 10) { return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 10 Zeichen lang sein." }); } if (HasAnyTwitchAuthSetting(twitchClientId, twitchClientSecret, twitchRedirectUri, twitchScope) && string.IsNullOrWhiteSpace(twitchClientId)) { return Results.BadRequest(new { message = "Twitch OAuth braucht eine Client-ID." }); } if (!string.IsNullOrWhiteSpace(twitchClientId) && string.IsNullOrWhiteSpace(twitchClientSecret) && !existingTwitchSecretAvailable) { return Results.BadRequest(new { message = "Twitch OAuth braucht beim ersten Speichern ein Client Secret." }); } 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; settings.TwitchClientId = twitchClientId; settings.TwitchRedirectUri = twitchRedirectUri; settings.TwitchScope = twitchScope; if (!string.IsNullOrWhiteSpace(twitchClientSecret)) { settings.TwitchClientSecret = twitchClientSecret; } else if (string.IsNullOrWhiteSpace(twitchClientId) && string.IsNullOrWhiteSpace(twitchRedirectUri) && string.IsNullOrWhiteSpace(twitchScope)) { settings.TwitchClientSecret = string.Empty; } settings.TwitchAuthManagedByDatabase = HasAnyTwitchAuthSetting( settings.TwitchClientId, settings.TwitchClientSecret, settings.TwitchRedirectUri, settings.TwitchScope); settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours; 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, configuration), !string.IsNullOrWhiteSpace(passwordToPersist), !string.IsNullOrWhiteSpace(twitchClientSecret)); adminAuditService.AddEntry( session.TwitchUserId, "operational-settings.update", "site-settings", settings.Id.ToString(), "Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.", new { settings.DemoLoginEnabled, passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist), settings.SessionIdleTimeoutHours, settings.MaintenanceModeEnabled, changes, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, demoLoginPasswordSet = HasDatabaseDemoCredentials(settings), twitchClientSecretSet = HasEffectiveTwitchClientSecret(settings, configuration), }); } 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 ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) => configuration[environmentKey] ?? configuration[$"TwitchAuth:{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 HasAnyTwitchAuthSetting(params string[] values) => values.Any(value => !string.IsNullOrWhiteSpace(value)); private static bool HasEffectiveTwitchClientSecret(SiteSettings settings, IConfiguration configuration) => !string.IsNullOrWhiteSpace(settings.TwitchClientSecret) || !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET")); private static TwitchSettingsSnapshot ReadEffectiveTwitchSettings(SiteSettings settings, IConfiguration configuration) { var clientId = !string.IsNullOrWhiteSpace(settings.TwitchClientId) ? settings.TwitchClientId : ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID"); var redirectUri = !string.IsNullOrWhiteSpace(settings.TwitchRedirectUri) ? settings.TwitchRedirectUri : ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI"); var scope = !string.IsNullOrWhiteSpace(settings.TwitchScope) ? settings.TwitchScope : ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE"); var secretSet = HasEffectiveTwitchClientSecret(settings, configuration); return new( clientId, secretSet, redirectUri, scope, !string.IsNullOrWhiteSpace(clientId) && secretSet); } private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings, IConfiguration configuration) => new( settings.DemoLoginManagedByDatabase, settings.DemoLoginEnabled, settings.DemoLoginEmail, HasDatabaseDemoCredentials(settings), settings.DemoLoginTwitchUserId, settings.DemoLoginDisplayName, settings.TwitchAuthManagedByDatabase, settings.TwitchClientId, HasEffectiveTwitchClientSecret(settings, configuration), settings.TwitchRedirectUri, settings.TwitchScope, UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours), 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, bool twitchClientSecretChanged) { var changes = new List(); 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); AddOperationalChange(changes, "twitchAuthManagedByDatabase", "Twitch OAuth Quelle", before.TwitchAuthManagedByDatabase, after.TwitchAuthManagedByDatabase); AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId); AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri); AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope); AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours); if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged) { changes.Add(new { field = "twitchClientSecret", label = "Twitch Client Secret", @from = before.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt", to = twitchClientSecretChanged ? "neu gesetzt" : after.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt", sensitive = true, }); } 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( ICollection changes, string field, string label, T before, T after) { if (EqualityComparer.Default.Equals(before, after)) { return; } changes.Add(new { field, label, @from = FormatOperationalAuditValue(before), to = FormatOperationalAuditValue(after), sensitive = false, }); } private static string FormatOperationalAuditValue(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 TwitchAuthManagedByDatabase, string TwitchClientId, bool TwitchClientSecretSet, string TwitchRedirectUri, string TwitchScope, int SessionIdleTimeoutHours, bool MaintenanceModeEnabled, string MaintenanceTitle, string MaintenanceMessage); private sealed record TwitchSettingsSnapshot( string ClientId, bool ClientSecretSet, string RedirectUri, string Scope, bool Configured); private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request) { if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage)) { return Results.BadRequest(new { message = errorMessage }); } var currentRules = TrackingRulesSettings.Read(settings); var configuration = new TrackingRulesConfiguration( new TrackingSourceSetting( TrackingRulesSettings.ProviderKey, normalizedBaseUrl, request.Source?.NotesSummary ?? currentRules.Source.NotesSummary, request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview), (request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(), (request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(), (request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray()); settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration); settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl; settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim(); return null; } private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings) { var rules = TrackingRulesSettings.Read(settings); return new AdminTrackingRulesResponse( new AdminTrackingSourceDto( rules.Source.ProviderKey, "TwitchTracker Basic API", TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl), rules.Source.NotesSummary, rules.Source.ShowManualReviewNotesInReview), rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(), rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(), rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(), settings.TrackingReviewNotes ?? string.Empty); } private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) => new( rule.Key, rule.Label, rule.Enabled, rule.SourceSupport, rule.Description, rule.RequiredForAutoClassification, rule.ShowInReview, rule.ShowInAdminSummary, rule.ManualOverrideAllowed, rule.WindowKey, rule.AutoSupportedWindowKeys, rule.ProviderFieldKey, rule.TopCount, rule.MinPrimaryCategorySharePercent, rule.MinPrimaryCategoryHours, rule.MaxDistinctCategoriesBeforeFlag, rule.IgnoredCategories, rule.MatchAwardCategoryAgainstTopCategories, rule.FlagIfAwardCategoryNotInTopX, rule.FlagIfCategorySpreadTooWide, rule.FlagIfNoCategoryContextAvailable, rule.MinValue, rule.MaxValue); private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) => new( rule.Key, rule.Label, rule.Enabled, rule.SourceSupport, rule.Description, rule.RequiredForAutoClassification, rule.ShowInReview, rule.ShowInAdminSummary, rule.ManualOverrideAllowed, TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d), (rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(), rule.TopCount, rule.MinPrimaryCategorySharePercent, rule.MinPrimaryCategoryHours, rule.MaxDistinctCategoriesBeforeFlag, (rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), rule.MatchAwardCategoryAgainstTopCategories, rule.FlagIfAwardCategoryNotInTopX, rule.FlagIfCategorySpreadTooWide, rule.FlagIfNoCategoryContextAvailable, rule.MinValue, rule.MaxValue); private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) => new( rule.Key, rule.Label, rule.Enabled, rule.Severity, rule.Description, rule.AutoTriggerEnabled, rule.RequiresManualReview, rule.BlocksApproval, rule.AdminNoteRequiredOnOverride); private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) => new( rule.Key, rule.Label, rule.Enabled, rule.Severity, rule.Description, rule.AutoTriggerEnabled, rule.RequiresManualReview, rule.BlocksApproval, rule.AdminNoteRequiredOnOverride); private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage) { normalizedValue = string.Empty; errorMessage = string.Empty; var trimmed = (rawValue ?? string.Empty).Trim(); if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein."; return false; } normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); return true; } }