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:
@@ -1,3 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
@@ -8,6 +11,8 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
|
||||
|
||||
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
@@ -45,13 +50,19 @@ public static partial class PublicEndpoints
|
||||
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private static async Task<IResult> CreateShowactApplication(
|
||||
HttpContext context,
|
||||
CreateShowactApplicationRequest request,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (settings is null || !settings.ShowactApplicationsEnabled)
|
||||
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
@@ -67,55 +78,137 @@ public static partial class PublicEndpoints
|
||||
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||
}
|
||||
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
||||
var hasDynamicForm = schema.Count > 0;
|
||||
|
||||
if (hasDynamicForm)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
Dictionary<string, string> responses;
|
||||
try
|
||||
{
|
||||
responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson)
|
||||
? new Dictionary<string, string>()
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(request.FieldResponsesJson, ShowactJsonOptions) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
MergeLegacyShowactFieldsIntoResponses(schema, responses, request);
|
||||
|
||||
var dynamicValidationError = ValidateShowactResponses(schema, responses);
|
||||
if (dynamicValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = dynamicValidationError });
|
||||
}
|
||||
|
||||
var artistNameField = schema.FirstOrDefault(f => f.IsArtistName);
|
||||
var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt";
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions);
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName[..Math.Min(artistName.Length, 120)],
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = technicalNotes,
|
||||
ReferenceUrl = referenceUrl,
|
||||
FieldResponsesJson = fieldResponsesJson,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
// Legacy fixed-fields path
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrValidEmail(contactEmail))
|
||||
{
|
||||
return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
private sealed class ShowactFieldDefinition
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||
[JsonPropertyName("required")] public bool Required { get; set; }
|
||||
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
|
||||
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
|
||||
}
|
||||
|
||||
private static string NormalizePublicText(string? value, int maxLength)
|
||||
@@ -128,4 +221,167 @@ public static partial class PublicEndpoints
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||
|
||||
private static bool IsBlankOrValidEmail(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
|
||||
|
||||
private static bool IsBlankOrValidJsonObject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(value);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Object;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateShowactResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses)
|
||||
{
|
||||
foreach (var field in schema)
|
||||
{
|
||||
var value = responses.TryGetValue(field.Id, out var rawValue)
|
||||
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
|
||||
: string.Empty;
|
||||
responses[field.Id] = value;
|
||||
|
||||
if (field.Required && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
|
||||
? $"Bitte bestaetige: {field.Label}"
|
||||
: $"{field.Label} ist erforderlich.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
|
||||
{
|
||||
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
|
||||
{
|
||||
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
|
||||
{
|
||||
if (field.MaxLength > 0)
|
||||
{
|
||||
return Math.Min(field.MaxLength, 2000);
|
||||
}
|
||||
|
||||
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
|
||||
}
|
||||
|
||||
private static void MergeLegacyShowactFieldsIntoResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses,
|
||||
CreateShowactApplicationRequest request)
|
||||
{
|
||||
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
|
||||
MergeResponseValue(artistField, request.ArtistName, responses, 120);
|
||||
|
||||
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
|
||||
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
|
||||
|
||||
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
|
||||
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
|
||||
|
||||
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
|
||||
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
|
||||
|
||||
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
|
||||
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
|
||||
|
||||
var performanceField = schema.FirstOrDefault(field =>
|
||||
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|
||||
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
|
||||
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
|
||||
|
||||
var descriptionField = schema.FirstOrDefault(field =>
|
||||
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
|
||||
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|
||||
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
|
||||
MergeResponseValue(descriptionField, request.Description, responses, 1000);
|
||||
|
||||
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
|
||||
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
|
||||
}
|
||||
|
||||
private static void MergeResponseValue(
|
||||
ShowactFieldDefinition? field,
|
||||
string? requestValue,
|
||||
IDictionary<string, string> responses,
|
||||
int maxLength)
|
||||
{
|
||||
if (field is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = NormalizePublicText(requestValue, maxLength);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
responses[field.Id] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Id.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Label.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user