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>
This commit is contained in:
@@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations
|
||||
.Include(item => item.Category)
|
||||
.Include(item => item.SuggestedCategory)
|
||||
.Include(item => item.StreamerIdentity)
|
||||
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||
|
||||
if (nomination is null)
|
||||
@@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Any(flag => flag.BlocksApproval)
|
||||
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
|
||||
}
|
||||
|
||||
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
||||
}
|
||||
|
||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
||||
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
|
||||
if (!categoryId.HasValue)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
|
||||
}
|
||||
|
||||
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||
item.Id == categoryId.Value
|
||||
&& item.SeasonId == nomination.SeasonId
|
||||
&& item.GroupName == nomination.CategoryGroupName);
|
||||
if (targetCategory is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
|
||||
}
|
||||
|
||||
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform)
|
||||
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
|
||||
: request.Platform.Trim();
|
||||
var normalizedDisplayName = rawDisplayName.ToLower();
|
||||
var normalizedChannelSlug = channelSlug.ToLower();
|
||||
var normalizedPlatform = platform.ToLower();
|
||||
|
||||
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryId == nomination.CategoryId
|
||||
&& item.CategoryId == targetCategory.Id
|
||||
&& (
|
||||
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
||
|
||||
item.DisplayName.ToLower() == normalizedDisplayName
|
||||
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||
&& item.Platform.ToLower() == normalizedPlatform)
|
||||
));
|
||||
|
||||
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
nomination.SeasonId,
|
||||
targetCategory.Id,
|
||||
existingCandidate?.Id,
|
||||
nomination.StreamerIdentityId,
|
||||
rawDisplayName,
|
||||
channelSlug,
|
||||
existingCandidate?.AcceptanceStatus ?? "open",
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
var candidate = existingCandidate;
|
||||
if (candidate is null)
|
||||
{
|
||||
candidate = new Candidate
|
||||
{
|
||||
SeasonId = nomination.SeasonId,
|
||||
CategoryId = nomination.CategoryId,
|
||||
CategoryId = targetCategory.Id,
|
||||
StreamerIdentityId = nomination.StreamerIdentityId,
|
||||
DisplayName = rawDisplayName,
|
||||
ChannelSlug = channelSlug,
|
||||
Platform = platform,
|
||||
@@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
|
||||
candidate.DisplayName = rawDisplayName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||
@@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
nomination.CandidateId = candidate.Id;
|
||||
nomination.Status = "approved";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var uniqueViewerCount = relatedNominations
|
||||
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
|
||||
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = candidate.Id;
|
||||
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
|
||||
relatedNomination.Status = "approved";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.approve",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
|
||||
new
|
||||
{
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
targetCategoryId = targetCategory.Id,
|
||||
targetCategoryName = targetCategory.Name,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
uniqueViewerCount,
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
nominationId = nomination.Id,
|
||||
candidateId = candidate.Id,
|
||||
created = existingCandidate is null,
|
||||
uniqueViewerCount,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectNomination(
|
||||
@@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
nomination.CandidateId = null;
|
||||
nomination.Status = "rejected";
|
||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "rejected";
|
||||
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.",
|
||||
new { nomination.ReviewNote },
|
||||
new
|
||||
{
|
||||
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReopenRejectedNomination(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
ReopenRejectedNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted);
|
||||
foreach (var relatedNomination in relatedNominations)
|
||||
{
|
||||
relatedNomination.CandidateId = null;
|
||||
relatedNomination.Status = "pending";
|
||||
relatedNomination.ReviewNote = null;
|
||||
relatedNomination.ReviewedAt = null;
|
||||
relatedNomination.ReviewedByTwitchId = null;
|
||||
}
|
||||
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.reopen",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.",
|
||||
new
|
||||
{
|
||||
reviewNote,
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateNominationTrackingReview(
|
||||
HttpContext context,
|
||||
int nominationId,
|
||||
UpdateNominationTrackingReviewRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
|
||||
if (nomination is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedStatus = request.Status?.Trim().ToLowerInvariant();
|
||||
if (normalizedStatus is not ("reviewed" or "overridden"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." });
|
||||
}
|
||||
|
||||
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||
var requiresOverrideNote = relatedNominations
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.Any(flag => flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." });
|
||||
}
|
||||
|
||||
foreach (var item in relatedNominations)
|
||||
{
|
||||
item.TrackingReviewStatus = normalizedStatus;
|
||||
item.TrackingReviewNote = reviewNote;
|
||||
item.TrackingReviewedByTwitchId = session.TwitchUserId;
|
||||
item.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination.tracking-review.update",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.",
|
||||
new
|
||||
{
|
||||
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||
status = normalizedStatus,
|
||||
reviewNote,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, nominationId, status = normalizedStatus });
|
||||
}
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedPendingNominationsAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
CancellationToken cancellationToken)
|
||||
=> await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken);
|
||||
|
||||
private static async Task<Nomination[]> FindRelatedNominationsByStatusAsync(
|
||||
AwardsDbContext db,
|
||||
Nomination nomination,
|
||||
string status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryGroupName == nomination.CategoryGroupName
|
||||
&& item.Status == status);
|
||||
|
||||
if (nomination.StreamerIdentityId.HasValue)
|
||||
{
|
||||
return await query
|
||||
.Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedStreamUrl))
|
||||
{
|
||||
var rows = await query.ToArrayAsync(cancellationToken);
|
||||
return rows
|
||||
.Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [nomination];
|
||||
}
|
||||
|
||||
private static string NormalizeModerationStreamUrl(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values) =>
|
||||
values
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||
?? string.Empty;
|
||||
|
||||
private static async Task<IResult?> BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
int? streamerIdentityId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
var rules = WorkflowRuleSettings.Read(season, settings);
|
||||
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingCandidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
|
||||
&& item.AcceptanceStatus != "declined")
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||
{
|
||||
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||
if (categoryCount >= finalistsRule.Limit)
|
||||
{
|
||||
return CreateModerationWorkflowRuleError(
|
||||
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
|
||||
}
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateModerationWorkflowRuleError(
|
||||
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult CreateModerationWorkflowRuleError(string message) =>
|
||||
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||
|
||||
private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
|
||||
if (trackingFlags.Length == 0)
|
||||
{
|
||||
nomination.TrackingReviewStatus = "clear";
|
||||
nomination.TrackingReviewNote = null;
|
||||
nomination.TrackingReviewedByTwitchId = null;
|
||||
nomination.TrackingReviewedAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim();
|
||||
nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note))
|
||||
? "overridden"
|
||||
: "reviewed";
|
||||
nomination.TrackingReviewNote = note;
|
||||
nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId;
|
||||
nomination.TrackingReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user