Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user