Files
vtuber-awards/Backend/Endpoints/PublicEndpointSupport.cs
T

209 lines
7.2 KiB
C#

using Backend.Common;
using Backend.Domain;
using Backend.Services;
using Microsoft.AspNetCore.Http.Extensions;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private readonly record struct SubmitterIdResolution(string? SubmitterId, IResult? Result);
private readonly record struct PublicWriteSeasonResolution(Season? Season, IResult? Result);
private sealed record PublicCandidateClip(
int? CategoryId,
int? CandidateId,
string Creator,
string ClipUrl,
string Title,
string Platform,
DateTimeOffset CreatedAt,
DateTimeOffset? ReviewedAt);
private static async Task<SubmitterIdResolution> ResolveSubmitterIdAsync(
HttpContext context,
string? fallbackTwitchUserId,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return new SubmitterIdResolution(
null,
Results.Json(
new { message = "A logged in user is required to submit this action." },
statusCode: StatusCodes.Status401Unauthorized));
}
var submittedTwitchUserId = NormalizeSubmittedTwitchUserId(fallbackTwitchUserId);
if (submittedTwitchUserId is not null
&& !string.Equals(submittedTwitchUserId, session.TwitchUserId, StringComparison.OrdinalIgnoreCase))
{
return new SubmitterIdResolution(
null,
Results.BadRequest(new { message = "Submitted user identity does not match the active session." }));
}
return new SubmitterIdResolution(session.TwitchUserId, null);
}
private static string? NormalizeSubmittedTwitchUserId(string? twitchUserId)
{
var normalized = twitchUserId?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static PublicWriteSeasonResolution EnsurePublicWriteSeason(
Season? season,
params string[] allowedPhaseKeys)
{
if (season is null)
{
return new PublicWriteSeasonResolution(null, Results.BadRequest(new { message = "The selected season does not exist." }));
}
if (!season.IsCurrent)
{
return new PublicWriteSeasonResolution(
null,
Results.BadRequest(new { message = "Submissions are only allowed for the active season." }));
}
var currentPhaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
if (!allowedPhaseKeys.Contains(currentPhaseKey, StringComparer.OrdinalIgnoreCase))
{
var phaseLabel = DescribePublicPhase(currentPhaseKey);
return new PublicWriteSeasonResolution(
null,
Results.BadRequest(new
{
message = $"This action is not available during the current season phase ({phaseLabel}).",
}));
}
return new PublicWriteSeasonResolution(season, null);
}
private static bool TryNormalizeExternalUrl(string? rawUrl, out string normalizedUrl)
{
normalizedUrl = string.Empty;
if (string.IsNullOrWhiteSpace(rawUrl))
{
return false;
}
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
{
return false;
}
if (uri.Scheme is not ("http" or "https"))
{
return false;
}
normalizedUrl = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
return true;
}
private static string? ResolveClipPlatform(string clipUrl)
{
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri))
{
return null;
}
var host = uri.Host.ToLowerInvariant();
if (host is "twitch.tv" or "www.twitch.tv" or "clips.twitch.tv")
{
return "Twitch";
}
if (host is "youtube.com" or "www.youtube.com" or "m.youtube.com" or "youtu.be")
{
return "YouTube";
}
return null;
}
private static bool ShouldExposePublicCategory(string phaseKey, int candidateCount) =>
string.Equals(phaseKey, "nomination", StringComparison.OrdinalIgnoreCase) || candidateCount > 0;
private static Dictionary<int, PublicCandidateClip> BuildCandidateClipLookup(IEnumerable<PublicCandidateClip> clips) =>
clips
.Where(clip => clip.CandidateId is not null)
.GroupBy(clip => clip.CandidateId!.Value)
.ToDictionary(
grouping => grouping.Key,
grouping => grouping
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
.First());
private static Dictionary<string, PublicCandidateClip> BuildCreatorClipLookup(IEnumerable<PublicCandidateClip> clips) =>
clips
.Where(clip => clip.CategoryId is not null && !string.IsNullOrWhiteSpace(clip.Creator))
.GroupBy(clip => BuildCandidateClipLookupKey(clip.CategoryId!.Value, clip.Creator))
.Where(grouping => !string.IsNullOrWhiteSpace(grouping.Key))
.ToDictionary(
grouping => grouping.Key,
grouping => grouping
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
.First());
private static PublicCandidateClip? ResolveCandidateClip(
Candidate candidate,
IReadOnlyDictionary<int, PublicCandidateClip> clipsByCandidateId,
IReadOnlyDictionary<string, PublicCandidateClip> clipsByCreatorKey)
{
if (clipsByCandidateId.TryGetValue(candidate.Id, out var directClip))
{
return directClip;
}
foreach (var key in BuildCandidateClipLookupKeys(candidate))
{
if (clipsByCreatorKey.TryGetValue(key, out var fallbackClip))
{
return fallbackClip;
}
}
return null;
}
private static IEnumerable<string> BuildCandidateClipLookupKeys(Candidate candidate)
{
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.DisplayName);
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug);
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug.TrimStart('@'));
}
private static string BuildCandidateClipLookupKey(int categoryId, string value)
{
var key = NormalizeCandidateClipKey(value);
return string.IsNullOrWhiteSpace(key) ? string.Empty : $"{categoryId}:{key}";
}
private static string NormalizeCandidateClipKey(string value)
{
var normalizedCharacters = value
.Trim()
.TrimStart('@')
.ToLowerInvariant()
.Where(char.IsLetterOrDigit)
.ToArray();
return new string(normalizedCharacters);
}
private static string DescribePublicPhase(string phaseKey) =>
phaseKey switch
{
"nomination" => "nomination",
"voting" => "voting",
"review" => "review",
"show" => "show",
_ => "current",
};
}