Refine admin workflows and team access
This commit is contained in:
@@ -75,9 +75,15 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks);
|
||||
if (urlValidationError is not null)
|
||||
{
|
||||
return urlValidationError;
|
||||
}
|
||||
|
||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||
settings.HostTagline = request.HostTagline.Trim();
|
||||
settings.NewsletterUrl = request.NewsletterUrl.Trim();
|
||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||
@@ -88,13 +94,13 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
settings.ImprintUrl = request.ImprintUrl.Trim();
|
||||
settings.ImprintUrl = normalizedUrls.ImprintUrl;
|
||||
settings.ImprintContent = request.ImprintContent.Trim();
|
||||
settings.ContactUrl = request.ContactUrl.Trim();
|
||||
settings.ContactUrl = normalizedUrls.ContactUrl;
|
||||
settings.ContactContent = request.ContactContent.Trim();
|
||||
settings.SponsorsUrl = request.SponsorsUrl.Trim();
|
||||
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
||||
settings.SponsorsContent = request.SponsorsContent.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
@@ -107,7 +113,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
{
|
||||
settings.HostDisplayName,
|
||||
privacyChanged,
|
||||
socialLinkCount = request.SocialLinks?.Length ?? 0,
|
||||
socialLinkCount = socialLinks.Length,
|
||||
faqCount = request.Faq?.Length ?? 0,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
@@ -116,6 +122,73 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.Ok(new { saved = true });
|
||||
}
|
||||
|
||||
private static IResult? NormalizeSiteSettingsUrls(
|
||||
UpdateSiteSettingsRequest request,
|
||||
out PublicSiteUrlSettings normalizedUrls,
|
||||
out PublicSocialLinkDto[] socialLinks)
|
||||
{
|
||||
normalizedUrls = new PublicSiteUrlSettings();
|
||||
socialLinks = [];
|
||||
|
||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
normalizedUrls = new PublicSiteUrlSettings
|
||||
{
|
||||
NewsletterUrl = newsletterUrl,
|
||||
ImprintUrl = imprintUrl,
|
||||
ContactUrl = contactUrl,
|
||||
SponsorsUrl = sponsorsUrl,
|
||||
};
|
||||
|
||||
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
||||
foreach (var social in request.SocialLinks ?? [])
|
||||
{
|
||||
if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
normalizedSocialLinks.Add(social with { Url = normalizedUrl });
|
||||
}
|
||||
|
||||
socialLinks = normalizedSocialLinks.ToArray();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
|
||||
{
|
||||
normalizedUrl = (value ?? string.Empty).Trim();
|
||||
errorMessage = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedUrl))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errorMessage = $"{fieldName} muss eine absolute http/https-URL sein.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedUrl = uri.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class PublicSiteUrlSettings
|
||||
{
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ImprintUrl { get; set; } = string.Empty;
|
||||
public string ContactUrl { get; set; } = string.Empty;
|
||||
public string SponsorsUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Backend.Endpoints;
|
||||
public static class AdminTeamEndpoints
|
||||
{
|
||||
private const int MinPasswordLength = 10;
|
||||
private static readonly TimeSpan OnlineActivityWindow = TimeSpan.FromMinutes(5);
|
||||
private const string PersonalOwnerLogin = "jayuhime";
|
||||
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
|
||||
|
||||
@@ -79,11 +80,21 @@ public static class AdminTeamEndpoints
|
||||
.OrderByDescending(item => item.Role == AdminRoles.Owner)
|
||||
.ThenBy(item => item.Role)
|
||||
.ThenBy(item => item.DisplayName)
|
||||
.Select(item => ToMemberDto(item))
|
||||
.ToArrayAsync();
|
||||
var sessionKeys = members
|
||||
.SelectMany(MemberSessionKeys)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var sessions = sessionKeys.Length == 0
|
||||
? []
|
||||
: await db.UserSessions
|
||||
.AsNoTracking()
|
||||
.Where(item => sessionKeys.Contains(item.TwitchUserId))
|
||||
.ToArrayAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
return Results.Ok(new AdminTeamResponse(
|
||||
members,
|
||||
members.Select(member => ToMemberDto(member, sessions, now)),
|
||||
await BuildRoleDtosAsync(db),
|
||||
PermissionCatalog));
|
||||
}
|
||||
@@ -322,10 +333,15 @@ public static class AdminTeamEndpoints
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
if (!AdminRoles.IsPrivilegedFullControlRole(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Rollenberechtigungen können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
|
||||
var roleChanges = new List<object>();
|
||||
|
||||
foreach (var roleUpdate in request.Roles ?? [])
|
||||
{
|
||||
@@ -339,14 +355,18 @@ public static class AdminTeamEndpoints
|
||||
.Select(item => item.Trim())
|
||||
.Where(item => allowedPermissions.Contains(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(item => item)
|
||||
.ToArray();
|
||||
permissionKeys = AdminPermissionCatalog.NormalizePermissionKeys(role, permissionKeys);
|
||||
|
||||
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
|
||||
}
|
||||
|
||||
var beforePermissions = existingRows.TryGetValue(role, out var existingRow)
|
||||
? AdminPermissionCatalog.NormalizePermissionKeys(role, ReadPermissionKeys(existingRow.PermissionsJson, AdminPermissionCatalog.DefaultPermissionKeys(role)))
|
||||
: AdminPermissionCatalog.NormalizePermissionKeys(role, AdminPermissionCatalog.DefaultPermissionKeys(role));
|
||||
|
||||
if (!existingRows.TryGetValue(role, out var row))
|
||||
{
|
||||
row = new TeamRolePermission { Role = role };
|
||||
@@ -356,6 +376,18 @@ public static class AdminTeamEndpoints
|
||||
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
row.UpdatedByTwitchId = session.TwitchUserId;
|
||||
|
||||
if (!beforePermissions.SequenceEqual(permissionKeys, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
roleChanges.Add(new
|
||||
{
|
||||
role,
|
||||
before = beforePermissions,
|
||||
after = permissionKeys,
|
||||
added = permissionKeys.Except(beforePermissions, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
removed = beforePermissions.Except(permissionKeys, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
@@ -364,7 +396,7 @@ public static class AdminTeamEndpoints
|
||||
"team-role",
|
||||
"matrix",
|
||||
"Team-Rollenberechtigungen wurden gespeichert.",
|
||||
new { roleCount = request.Roles?.Length ?? 0 },
|
||||
new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -384,18 +416,30 @@ public static class AdminTeamEndpoints
|
||||
? ReadPermissionKeys(json, role.PermissionKeys)
|
||||
: role.PermissionKeys;
|
||||
|
||||
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
|
||||
{
|
||||
permissions = AdminPermissionCatalog.AllPermissionKeys;
|
||||
}
|
||||
permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions);
|
||||
|
||||
return role with { PermissionKeys = permissions };
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
|
||||
new(
|
||||
private static AdminTeamMemberDto ToMemberDto(
|
||||
TeamMember member,
|
||||
IReadOnlyCollection<UserSession>? sessions = null,
|
||||
DateTimeOffset? now = null)
|
||||
{
|
||||
var memberSessions = sessions?
|
||||
.Where(session => MemberSessionKeys(member).Contains(session.TwitchUserId, StringComparer.OrdinalIgnoreCase))
|
||||
.ToArray() ?? [];
|
||||
var lastSeenAt = memberSessions
|
||||
.Select(session => (DateTimeOffset?)session.LastSeenAt)
|
||||
.Max() ?? member.LastLoginAt;
|
||||
var isOnline = member.IsActive
|
||||
&& memberSessions.Any(session =>
|
||||
session.IsActive
|
||||
&& session.LastSeenAt >= (now ?? DateTimeOffset.UtcNow).Subtract(OnlineActivityWindow));
|
||||
|
||||
return new(
|
||||
member.Id,
|
||||
member.Login,
|
||||
member.DisplayName,
|
||||
@@ -407,8 +451,21 @@ public static class AdminTeamEndpoints
|
||||
member.CreatedAt,
|
||||
member.UpdatedAt,
|
||||
member.LastLoginAt,
|
||||
lastSeenAt,
|
||||
isOnline,
|
||||
member.TwitchBoundAt,
|
||||
member.PasswordResetAt);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> MemberSessionKeys(TeamMember member)
|
||||
{
|
||||
yield return $"team:{member.Login}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId))
|
||||
{
|
||||
yield return member.BoundTwitchUserId;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
|
||||
{
|
||||
|
||||
@@ -23,21 +23,11 @@ public static partial class AuthEndpoints
|
||||
.WithName("TeamLogin")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/team-twitch-login", TeamTwitchLogin)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("TeamTwitchLogin")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/password/change", ChangePassword)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("ChangePassword")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/team/twitch-binding", BindTeamTwitch)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("BindTeamTwitch")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/twitch/authorize", StartTwitchAuthorization)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("StartTwitchAuthorization")
|
||||
|
||||
@@ -47,38 +47,6 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> TeamTwitchLogin(
|
||||
HttpContext context,
|
||||
TeamTwitchLoginRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
|
||||
if (string.IsNullOrWhiteSpace(twitchUserId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var member = await db.TeamMembers.FirstOrDefaultAsync(
|
||||
item => item.BoundTwitchUserId == twitchUserId,
|
||||
context.RequestAborted);
|
||||
if (member is null || !member.IsActive)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
var session = await userSessionService.CreateSessionAsync(
|
||||
twitchUserId,
|
||||
member.DisplayName,
|
||||
member.Role,
|
||||
RequestMetadataReader.Read(context),
|
||||
context.RequestAborted);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(
|
||||
HttpContext context,
|
||||
ChangePasswordRequest request,
|
||||
@@ -127,64 +95,6 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> BindTeamTwitch(
|
||||
HttpContext context,
|
||||
BindTeamTwitchRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||
if (member is null || !member.IsActive)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." });
|
||||
}
|
||||
|
||||
if (member.MustChangePassword)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." });
|
||||
}
|
||||
|
||||
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
|
||||
var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName);
|
||||
if (string.IsNullOrWhiteSpace(twitchUserId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." });
|
||||
}
|
||||
|
||||
var alreadyBound = await db.TeamMembers.AnyAsync(
|
||||
item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId,
|
||||
context.RequestAborted);
|
||||
if (alreadyBound)
|
||||
{
|
||||
return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." });
|
||||
}
|
||||
|
||||
var previousTwitchUserId = member.BoundTwitchUserId;
|
||||
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
|
||||
&& !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
|
||||
{
|
||||
oldSession.IsActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
member.BoundTwitchUserId = twitchUserId;
|
||||
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName;
|
||||
member.TwitchBoundAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static string BuildTeamSessionId(string login) =>
|
||||
$"{TeamSessionPrefix}{login}";
|
||||
|
||||
@@ -215,7 +125,4 @@ public static partial class AuthEndpoints
|
||||
|
||||
private static string NormalizeTwitchUserId(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||
|
||||
private static string NormalizeTwitchDisplayName(string? value) =>
|
||||
(value ?? string.Empty).Trim();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
@@ -72,29 +73,7 @@ public static partial class PublicEndpoints
|
||||
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
|
||||
|
||||
var isResubmission = ballot is not null;
|
||||
if (ballot is null)
|
||||
{
|
||||
ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = request.SeasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
};
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||
ballot.Entries.Clear();
|
||||
}
|
||||
|
||||
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
ballot.Status = "submitted";
|
||||
ballot.Entries = request.Entries.Select(entry => new VoteEntry
|
||||
{
|
||||
CategoryId = entry.CategoryId,
|
||||
CandidateId = entry.CandidateId,
|
||||
}).ToList();
|
||||
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
|
||||
|
||||
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
|
||||
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
|
||||
@@ -102,7 +81,21 @@ public static partial class PublicEndpoints
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
ballot = await db.VoteBallots
|
||||
.Include(item => item.Entries)
|
||||
.FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
|
||||
isResubmission = true;
|
||||
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
var ballotLink = new
|
||||
{
|
||||
label = "Voting-Analytics öffnen",
|
||||
@@ -142,4 +135,42 @@ public static partial class PublicEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
|
||||
}
|
||||
|
||||
private static async Task<VoteBallot> ApplyVoteEntriesAsync(
|
||||
AwardsDbContext db,
|
||||
VoteBallot? ballot,
|
||||
int seasonId,
|
||||
string submitterId,
|
||||
VoteEntryRequest[] entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ballot is null)
|
||||
{
|
||||
ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
};
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||
ballot.Entries.Clear();
|
||||
}
|
||||
|
||||
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
ballot.Status = "submitted";
|
||||
ballot.Entries = entries.Select(entry => new VoteEntry
|
||||
{
|
||||
CategoryId = entry.CategoryId,
|
||||
CandidateId = entry.CandidateId,
|
||||
}).ToList();
|
||||
|
||||
return ballot;
|
||||
}
|
||||
|
||||
private static bool IsUniqueVoteBallotViolation(DbUpdateException error) =>
|
||||
error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
|
||||
}
|
||||
|
||||
@@ -9,10 +9,25 @@ public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Year == year)
|
||||
.Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase })
|
||||
.FirstOrDefaultAsync();
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
|
||||
{
|
||||
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||
}
|
||||
|
||||
var winnerRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year == year)
|
||||
.Where(result => result.SeasonId == season.Id)
|
||||
.OrderBy(result => result.CategoryName)
|
||||
.Select(result => new
|
||||
{
|
||||
@@ -34,4 +49,10 @@ public static partial class PublicEndpoints
|
||||
|
||||
return Results.Ok(new WinnerArchiveResponse(year, items));
|
||||
}
|
||||
|
||||
private static bool CanExposeCurrentSeasonWinners(string currentPhase)
|
||||
{
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
return phaseKey is "completed";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user