209 lines
7.2 KiB
C#
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",
|
|
};
|
|
}
|