b53c7fb736
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>
388 lines
16 KiB
C#
388 lines
16 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.RegularExpressions;
|
|
using Backend.Common;
|
|
using Backend.Contracts;
|
|
using Backend.Data;
|
|
using Backend.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
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
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Year == year);
|
|
|
|
if (season is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
|
if (settings is null || !settings.SponsorsVisible)
|
|
{
|
|
return Results.Ok(new PublicSponsorsResponse(year, []));
|
|
}
|
|
|
|
var sponsors = await db.Sponsors
|
|
.AsNoTracking()
|
|
.Where(item => item.SeasonId == season.Id && item.IsVisible)
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Name)
|
|
.Select(item => new SponsorDto(
|
|
item.Id,
|
|
item.SeasonId,
|
|
item.Name,
|
|
item.WebsiteUrl,
|
|
item.LogoUrl,
|
|
item.Description,
|
|
item.Tier,
|
|
item.SortOrder,
|
|
item.IsVisible))
|
|
.ToArrayAsync();
|
|
|
|
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 || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
|
{
|
|
return Results.BadRequest(new
|
|
{
|
|
message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage)
|
|
? "Showact-Bewerbungen sind aktuell geschlossen."
|
|
: settings.ShowactApplicationDisabledMessage,
|
|
});
|
|
}
|
|
|
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted);
|
|
if (season is null)
|
|
{
|
|
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
|
}
|
|
|
|
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
|
{
|
|
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
|
}
|
|
|
|
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
|
var hasDynamicForm = schema.Count > 0;
|
|
|
|
if (hasDynamicForm)
|
|
{
|
|
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 });
|
|
}
|
|
else
|
|
{
|
|
// 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 });
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
var trimmed = (value ?? string.Empty).Trim();
|
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
|
}
|
|
|
|
private static bool IsBlankOrHttpUrl(string value) =>
|
|
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);
|
|
}
|
|
}
|