Align backend with frontend, make clips end-to-end, polish admin
Backend - Add ClipSubmission entity + table (runtime bootstrapper) and POST /api/public/clips (server-derives platform from the link) - Surface clip submissions in the admin season detail - Add DELETE candidate/category/clip endpoints with audit entries Frontend - Clips admin: real moderation view (list, open link, delete) instead of placeholder; wired clipSubmissions through types/api/store - Categories admin: add delete with confirm modal (matches Candidates) - Voting ranking bar uses the unified purple gradient - Fix ASCII transliterations to proper German umlauts across admin - Remove orphan AdminView 2.vue Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,17 @@ public sealed record AdminNominationReviewItemDto(
|
||||
string CandidateText,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AdminClipSubmissionItemDto(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
string SubmittedByTwitchId,
|
||||
string ClipUrl,
|
||||
string Title,
|
||||
string Creator,
|
||||
string Platform,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AdminSeasonDetailResponse(
|
||||
int Id,
|
||||
int Year,
|
||||
@@ -75,7 +86,8 @@ public sealed record AdminSeasonDetailResponse(
|
||||
bool IsCurrent,
|
||||
IEnumerable<AdminCategoryItemDto> Categories,
|
||||
IEnumerable<AdminCandidateItemDto> Candidates,
|
||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations);
|
||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
||||
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
||||
|
||||
public sealed record UpdateSeasonRequest(
|
||||
string CurrentPhase,
|
||||
|
||||
@@ -77,3 +77,11 @@ public sealed record CreateVoteRequest(
|
||||
int SeasonId,
|
||||
string TwitchUserId,
|
||||
VoteEntryRequest[] Entries);
|
||||
|
||||
public sealed record CreateClipRequest(
|
||||
int Year,
|
||||
int? CategoryId,
|
||||
string TwitchUserId,
|
||||
string ClipUrl,
|
||||
string Title,
|
||||
string Creator);
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -90,6 +91,18 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClipSubmission>(entity =>
|
||||
{
|
||||
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.ClipUrl).HasMaxLength(500);
|
||||
entity.Property(item => item.Title).HasMaxLength(200);
|
||||
entity.Property(item => item.Creator).HasMaxLength(120);
|
||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||
entity.Property(item => item.Status).HasMaxLength(20);
|
||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||
});
|
||||
|
||||
SeedData.Apply(modelBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,5 +49,22 @@ public static class OperationalTablesBootstrapper
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
|
||||
ON "AdminAuditEntries" ("CreatedAt" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"SeasonId" integer NOT NULL,
|
||||
"CategoryId" integer NULL,
|
||||
"SubmittedByTwitchId" character varying(120) NOT NULL,
|
||||
"ClipUrl" character varying(500) NOT NULL,
|
||||
"Title" character varying(200) NOT NULL,
|
||||
"Creator" character varying(120) NOT NULL,
|
||||
"Platform" character varying(40) NOT NULL,
|
||||
"Status" character varying(20) NOT NULL,
|
||||
"CreatedFromIp" character varying(80) NOT NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
|
||||
ON "ClipSubmissions" ("SeasonId", "Status");
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class ClipSubmission
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SeasonId { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
||||
public string ClipUrl { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Creator { get; set; } = string.Empty;
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "pending";
|
||||
public string CreatedFromIp { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
+179
-1
@@ -621,6 +621,70 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
|
||||
.WithName("CreateVote")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapPost("/api/public/clips", async (HttpContext context, CreateClipRequest request, AwardsDbContext db) =>
|
||||
{
|
||||
var clipUrl = request.ClipUrl?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(clipUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A clip link is required." });
|
||||
}
|
||||
|
||||
var loweredUrl = clipUrl.ToLowerInvariant();
|
||||
var platform = loweredUrl.Contains("twitch.tv")
|
||||
? "Twitch"
|
||||
: loweredUrl.Contains("youtube.com") || loweredUrl.Contains("youtu.be")
|
||||
? "YouTube"
|
||||
: "Other";
|
||||
|
||||
if (platform == "Other")
|
||||
{
|
||||
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected season does not exist." });
|
||||
}
|
||||
|
||||
if (request.CategoryId is int categoryId)
|
||||
{
|
||||
var categoryExists = await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
|
||||
if (!categoryExists)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||
}
|
||||
}
|
||||
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
var submitterId = session?.TwitchUserId ?? request.TwitchUserId;
|
||||
if (string.IsNullOrWhiteSpace(submitterId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A logged in user is required to submit clips." });
|
||||
}
|
||||
|
||||
var clip = new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = request.CategoryId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
ClipUrl = clipUrl,
|
||||
Title = request.Title?.Trim() ?? string.Empty,
|
||||
Creator = request.Creator?.Trim() ?? string.Empty,
|
||||
Platform = platform,
|
||||
Status = "pending",
|
||||
CreatedFromIp = ReadClientIp(context),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ClipSubmissions.Add(clip);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, clipId = clip.Id });
|
||||
})
|
||||
.WithName("CreateClip")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) =>
|
||||
{
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
@@ -809,6 +873,23 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var clipSubmissions = await db.ClipSubmissions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(100)
|
||||
.Select(item => new AdminClipSubmissionItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.SubmittedByTwitchId,
|
||||
item.ClipUrl,
|
||||
item.Title,
|
||||
item.Creator,
|
||||
item.Platform,
|
||||
item.Status,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(new AdminSeasonDetailResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
@@ -817,7 +898,8 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
|
||||
season.IsCurrent,
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations));
|
||||
pendingNominations,
|
||||
clipSubmissions));
|
||||
})
|
||||
.WithName("GetAdminSeasonDetail")
|
||||
.WithOpenApi();
|
||||
@@ -1013,6 +1095,102 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
|
||||
.WithName("UpdateAdminCandidate")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapDelete("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, AwardsDbContext db) =>
|
||||
{
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
if (session?.Role != "admin")
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
|
||||
if (candidate is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.Candidates.Remove(candidate);
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"candidate.delete",
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
|
||||
new { candidate.CategoryId, candidate.Platform });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { deleted = true, candidateId });
|
||||
})
|
||||
.WithName("DeleteAdminCandidate")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapDelete("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, AwardsDbContext db) =>
|
||||
{
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
if (session?.Role != "admin")
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
|
||||
if (category is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync();
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
|
||||
db.Categories.Remove(category);
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"category.delete",
|
||||
"category",
|
||||
category.Id.ToString(),
|
||||
$"Kategorie {category.Name} wurde gelöscht.",
|
||||
new { removedCandidates = candidates.Length });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { deleted = true, categoryId });
|
||||
})
|
||||
.WithName("DeleteAdminCategory")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapDelete("/api/admin/clips/{clipId:int}", async (HttpContext context, int clipId, AwardsDbContext db) =>
|
||||
{
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
if (session?.Role != "admin")
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
|
||||
if (clip is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.ClipSubmissions.Remove(clip);
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"clip.delete",
|
||||
"clip",
|
||||
clip.Id.ToString(),
|
||||
$"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.",
|
||||
new { clip.Platform });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { deleted = true, clipId });
|
||||
})
|
||||
.WithName("DeleteAdminClip")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) =>
|
||||
{
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
|
||||
Reference in New Issue
Block a user