From 24f9a690225abb6eb2a41714a03aab40d62dbd7c Mon Sep 17 00:00:00 2001 From: AzuTear Date: Thu, 18 Jun 2026 07:53:19 +0200 Subject: [PATCH] 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 --- Backend/Contracts/AdminContracts.cs | 14 +- Backend/Contracts/PublicContracts.cs | 8 + Backend/Data/AwardsDbContext.cs | 13 ++ Backend/Data/OperationalTablesBootstrapper.cs | 17 ++ Backend/Domain/ClipSubmission.cs | 16 ++ Backend/Program.cs | 180 ++++++++++++++++- .../components/admin/AdminSeasonToolbar.vue | 4 +- frontend/src/lib/api.ts | 2 + frontend/src/stores/awards.ts | 19 ++ frontend/src/types/awards.ts | 13 ++ .../src/views/admin/AdminAnalyticsView.vue | 4 +- .../src/views/admin/AdminCategoriesView.vue | 51 ++++- frontend/src/views/admin/AdminClipsView.vue | 186 +++++++++++------- .../src/views/admin/AdminDashboardView.vue | 26 +-- frontend/src/views/admin/AdminLayoutView.vue | 8 +- .../src/views/admin/AdminNominationsView.vue | 2 +- frontend/src/views/admin/AdminReviewsView.vue | 16 +- frontend/src/views/admin/AdminRiskView.vue | 20 +- frontend/src/views/admin/AdminSeasonsView.vue | 20 +- .../src/views/admin/AdminSettingsView.vue | 8 +- .../src/views/admin/AdminUsersLogsView.vue | 14 +- frontend/src/views/admin/AdminVotingView.vue | 8 +- 22 files changed, 504 insertions(+), 145 deletions(-) create mode 100644 Backend/Domain/ClipSubmission.cs diff --git a/Backend/Contracts/AdminContracts.cs b/Backend/Contracts/AdminContracts.cs index 959d31c..8483cea 100644 --- a/Backend/Contracts/AdminContracts.cs +++ b/Backend/Contracts/AdminContracts.cs @@ -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 Categories, IEnumerable Candidates, - IEnumerable PendingNominations); + IEnumerable PendingNominations, + IEnumerable ClipSubmissions); public sealed record UpdateSeasonRequest( string CurrentPhase, diff --git a/Backend/Contracts/PublicContracts.cs b/Backend/Contracts/PublicContracts.cs index eb0fae3..dd2b8a9 100644 --- a/Backend/Contracts/PublicContracts.cs +++ b/Backend/Contracts/PublicContracts.cs @@ -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); diff --git a/Backend/Data/AwardsDbContext.cs b/Backend/Data/AwardsDbContext.cs index 7bbbe50..bde6be6 100644 --- a/Backend/Data/AwardsDbContext.cs +++ b/Backend/Data/AwardsDbContext.cs @@ -15,6 +15,7 @@ public sealed class AwardsDbContext(DbContextOptions options) : public DbSet UserSessions => Set(); public DbSet RiskFlags => Set(); public DbSet AdminAuditEntries => Set(); + public DbSet ClipSubmissions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -90,6 +91,18 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.Summary).HasMaxLength(240); }); + modelBuilder.Entity(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); } } diff --git a/Backend/Data/OperationalTablesBootstrapper.cs b/Backend/Data/OperationalTablesBootstrapper.cs index 1a36144..7428feb 100644 --- a/Backend/Data/OperationalTablesBootstrapper.cs +++ b/Backend/Data/OperationalTablesBootstrapper.cs @@ -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"); """); } diff --git a/Backend/Domain/ClipSubmission.cs b/Backend/Domain/ClipSubmission.cs new file mode 100644 index 0000000..caf809a --- /dev/null +++ b/Backend/Domain/ClipSubmission.cs @@ -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; } +} diff --git a/Backend/Program.cs b/Backend/Program.cs index 4a924f5..8a841da 100644 --- a/Backend/Program.cs +++ b/Backend/Program.cs @@ -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); diff --git a/frontend/src/components/admin/AdminSeasonToolbar.vue b/frontend/src/components/admin/AdminSeasonToolbar.vue index e8a0661..a9c1a97 100644 --- a/frontend/src/components/admin/AdminSeasonToolbar.vue +++ b/frontend/src/components/admin/AdminSeasonToolbar.vue @@ -42,7 +42,7 @@ const yearStats = computed(() => [

Aktuelles Award-Jahr

- {{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswaehlen' }} + {{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswählen' }}

@@ -54,7 +54,7 @@ const yearStats = computed(() => [ - {{ currentSeason.isCurrent ? 'Oeffentlich sichtbar' : 'Nicht oeffentlich' }} + {{ currentSeason.isCurrent ? 'Öffentlich sichtbar' : 'Nicht öffentlich' }} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c5a181b..4debbf6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -112,6 +112,8 @@ export const api = { sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`), deleteAdminCategory: (categoryId: number) => sendDelete<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`), + deleteAdminClip: (clipId: number) => + sendDelete<{ deleted: boolean; clipId: number }>(`/api/admin/clips/${clipId}`), approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) => sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>( `/api/admin/nominations/${nominationId}/approve`, diff --git a/frontend/src/stores/awards.ts b/frontend/src/stores/awards.ts index 8e06163..94e945d 100644 --- a/frontend/src/stores/awards.ts +++ b/frontend/src/stores/awards.ts @@ -179,6 +179,19 @@ const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = { createdAt: '2026-06-17T08:00:00Z', }, ], + clipSubmissions: [ + { + id: 1, + categoryId: 1, + submittedByTwitchId: 'demo_user', + clipUrl: 'https://clips.twitch.tv/DemoClip', + title: 'Epischer Clutch im Finale', + creator: 'Hoshimi Miyu', + platform: 'Twitch', + status: 'pending', + createdAt: '2026-06-17T09:10:00Z', + }, + ], } const emptyAdmin: AdminDashboardResponse = { @@ -200,6 +213,7 @@ const emptyAdminSeasonDetail: AdminSeasonDetailResponse = { categories: [], candidates: [], pendingNominations: [], + clipSubmissions: [], } export const useAwardsStore = defineStore('awards', { @@ -317,6 +331,11 @@ export const useAwardsStore = defineStore('awards', { await this.loadAdminSeasonDetail(seasonId) return result }, + async deleteAdminClip(clipId: number, seasonId: number) { + const result = await api.deleteAdminClip(clipId) + await this.loadAdminSeasonDetail(seasonId) + return result + }, async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) { const result = await api.approveAdminNomination(nominationId, payload) await this.loadAdminSeasonDetail(seasonId) diff --git a/frontend/src/types/awards.ts b/frontend/src/types/awards.ts index 22b44db..f7fd344 100644 --- a/frontend/src/types/awards.ts +++ b/frontend/src/types/awards.ts @@ -159,6 +159,18 @@ export interface AdminNominationReviewItem { createdAt: string } +export interface AdminClipSubmissionItem { + id: number + categoryId: number | null + submittedByTwitchId: string + clipUrl: string + title: string + creator: string + platform: string + status: string + createdAt: string +} + export interface AdminSeasonDetailResponse { id: number year: number @@ -168,6 +180,7 @@ export interface AdminSeasonDetailResponse { categories: AdminCategoryItem[] candidates: AdminCandidateItem[] pendingNominations: AdminNominationReviewItem[] + clipSubmissions: AdminClipSubmissionItem[] } export interface CreateNominationPayload { diff --git a/frontend/src/views/admin/AdminAnalyticsView.vue b/frontend/src/views/admin/AdminAnalyticsView.vue index 7b18662..ee8f874 100644 --- a/frontend/src/views/admin/AdminAnalyticsView.vue +++ b/frontend/src/views/admin/AdminAnalyticsView.vue @@ -37,7 +37,7 @@ const insights = computed(() => { { label: 'Votes pro Kandidat', value: votesPerCandidate, - note: 'Hilft einzuschaetzen, ob die Kandidatenbasis breit genug ist.', + note: 'Hilft einzuschätzen, ob die Kandidatenbasis breit genug ist.', }, { label: 'Leere Kategorien', @@ -58,7 +58,7 @@ const insights = computed(() => { diff --git a/frontend/src/views/admin/AdminCategoriesView.vue b/frontend/src/views/admin/AdminCategoriesView.vue index 2ce3719..0e098a2 100644 --- a/frontend/src/views/admin/AdminCategoriesView.vue +++ b/frontend/src/views/admin/AdminCategoriesView.vue @@ -1,12 +1,14 @@