Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season

Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-28 23:32:21 +02:00
parent 4b2c5fa15d
commit b53c7fb736
178 changed files with 28933 additions and 1908 deletions
+342 -3
View File
@@ -41,6 +41,22 @@ public static class AdminSiteSettingsEndpoints
.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;
}
@@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName,
settings.HostTagline,
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
@@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings)));
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]"));
}
private static async Task<IResult> UpdateSiteSettings(
@@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
@@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints
settings.ShowactsContent = request.ShowactsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints
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)
@@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints
normalizedUrls = new PublicSiteUrlSettings
{
NewsletterUrl = newsletterUrl,
ShareXUrl = shareXUrl,
ShareDiscordUrl = shareDiscordUrl,
ImprintUrl = imprintUrl,
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
@@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints
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;
@@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> UpdateOptionalFeatureSettings(
HttpContext context,
UpdateOptionalFeatureSettingsRequest request,
@@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints
}
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,
@@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints
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;
@@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints
? 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,
@@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints
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();
@@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
return Results.Ok(new AdminOperationalSettingsResponse(
usesDatabaseDemo,
@@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints
twitchSettings.ClientSecretSet,
twitchSettings.RedirectUri,
twitchSettings.Scope,
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
@@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints
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"));
@@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints
settings.TwitchClientSecret,
settings.TwitchRedirectUri,
settings.TwitchScope);
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
? newPassword
@@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints
"operational-settings.update",
"site-settings",
settings.Id.ToString(),
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
new
{
settings.DemoLoginEnabled,
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
settings.SessionIdleTimeoutHours,
settings.MaintenanceModeEnabled,
changes,
},
@@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints
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);
@@ -576,6 +781,7 @@ public static class AdminSiteSettingsEndpoints
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)
{
@@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints
bool TwitchClientSecretSet,
string TwitchRedirectUri,
string TwitchScope,
int SessionIdleTimeoutHours,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
@@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints
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;
}
}