Files
vtuber-awards/Backend/Endpoints/PublicExtrasEndpoints.cs
T
AzuTear b53c7fb736 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>
2026-06-28 23:32:21 +02:00

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);
}
}