Files
vtuber-awards/Backend/Program.cs
T
2026-06-17 11:35:45 +02:00

817 lines
25 KiB
C#

using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration["VTSA_POSTGRES"]
?? builder.Configuration.GetConnectionString("Postgres");
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddCors(options =>
{
options.AddPolicy("frontend", policy =>
{
policy
.WithOrigins(
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddDbContext<AwardsDbContext>(options =>
{
options.UseNpgsql(connectionString);
});
var app = builder.Build();
static string? ReadBearerToken(HttpContext context)
{
var header = context.Request.Headers.Authorization.ToString();
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? header["Bearer ".Length..].Trim()
: null;
}
static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsDbContext db)
{
var token = ReadBearerToken(context);
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
var session = await db.UserSessions.FirstOrDefaultAsync(item => item.SessionToken == token && item.IsActive);
if (session is not null)
{
session.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
return session;
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("frontend");
app.UseHttpsRedirection();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AwardsDbContext>();
if (app.Environment.IsDevelopment())
{
try
{
db.Database.Migrate();
}
catch
{
// In local environments without PostgreSQL yet, the API should still boot
// so frontend work and migration generation can continue independently.
}
}
try
{
await SessionBootstrapper.EnsureAsync(db);
}
catch
{
// If the session table bootstrap fails, the rest of the API can still start.
}
}
app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext db) =>
{
var session = new UserSession
{
Id = Guid.NewGuid(),
SessionToken = Guid.NewGuid().ToString("N"),
TwitchUserId = request.TwitchUserId.Trim(),
DisplayName = request.DisplayName.Trim(),
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
CreatedAt = DateTimeOffset.UtcNow,
LastSeenAt = DateTimeOffset.UtcNow,
IsActive = true,
};
db.UserSessions.Add(session);
await db.SaveChangesAsync();
return Results.Ok(new AuthSessionDto(
session.SessionToken,
session.TwitchUserId,
session.DisplayName,
session.Role));
})
.WithName("DevLogin")
.WithOpenApi();
app.MapGet("/api/auth/session", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session is null)
{
return Results.Unauthorized();
}
return Results.Ok(new AuthSessionDto(
session.SessionToken,
session.TwitchUserId,
session.DisplayName,
session.Role));
})
.WithName("GetSession")
.WithOpenApi();
app.MapPost("/api/auth/logout", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session is null)
{
return Results.Ok(new { loggedOut = true });
}
session.IsActive = false;
await db.SaveChangesAsync();
return Results.Ok(new { loggedOut = true });
})
.WithName("Logout")
.WithOpenApi();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" }))
.WithName("GetHealth")
.WithOpenApi();
app.MapGet("/api/health/database", async (AwardsDbContext db) =>
{
try
{
var canConnect = await db.Database.CanConnectAsync();
var pendingMigrations = canConnect
? await db.Database.GetPendingMigrationsAsync()
: Array.Empty<string>();
return Results.Ok(new
{
provider = "postgres",
canConnect,
pendingMigrations,
configuredConnection = new
{
source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings",
},
});
}
catch (Exception exception)
{
return Results.Ok(new
{
provider = "postgres",
canConnect = false,
pendingMigrations = Array.Empty<string>(),
configuredConnection = new
{
source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings",
},
error = exception.Message,
});
}
})
.WithName("GetDatabaseHealth")
.WithOpenApi();
app.MapGet("/api/public/overview", async (AwardsDbContext db) =>
{
var season = await db.Seasons
.AsNoTracking()
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
.Include(item => item.Results)
.ThenInclude(result => result.Candidate)
.FirstOrDefaultAsync(item => item.IsCurrent);
if (season is null)
{
return Results.NotFound();
}
var response = new OverviewResponse(
season.Id,
season.Year,
season.Name,
season.ShowDate,
season.CurrentPhase,
season.IsCommunityOnly,
"Twitch",
new[]
{
new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, "done"),
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, "active"),
new TimelineItem("review", "Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, "upcoming"),
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, "upcoming"),
},
season.Categories
.Take(6)
.Select(category => new FeaturedCategoryDto(
category.Id,
category.GroupName,
category.Name,
category.Description,
category.MaxNomineesPerUser))
.ToArray(),
season.Results
.OrderByDescending(result => result.SeasonId)
.Take(4)
.Select(result => new WinnerPreviewDto(
season.Year,
result.CategoryName,
result.Candidate.DisplayName,
result.Candidate.ChannelSlug))
.ToArray(),
new[]
{
new FaqItemDto("Wer kann nominieren und voten?", "Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt."),
new FaqItemDto("Wie werden Gewinner bestimmt?", "Aktuell rein community-basiert. Eine Mischlogik mit Jury oder Panel kann spaeter eingefuehrt werden."),
new FaqItemDto("Wer verwaltet Kategorien und Unterkategorien?", "Das Team pflegt diese pro Jahr im Admin-Bereich."),
});
return Results.Ok(response);
})
.WithName("GetOverview")
.WithOpenApi();
app.MapGet("/api/public/seasons/{year:int}/categories", async (int year, AwardsDbContext db) =>
{
var season = await db.Seasons
.AsNoTracking()
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
.ThenInclude(category => category.Candidates.OrderBy(candidate => candidate.DisplayName))
.FirstOrDefaultAsync(item => item.Year == year);
if (season is null)
{
return Results.NotFound();
}
return Results.Ok(new SeasonCategoriesResponse(
season.Id,
season.Year,
season.Categories.Select(category => new PublicCategoryDetailDto(
category.Id,
category.Name,
category.GroupName,
category.Description,
category.MaxNomineesPerUser,
category.Candidates.Select(candidate => new CandidateSummaryDto(
candidate.Id,
candidate.DisplayName,
candidate.ChannelSlug,
candidate.Platform))
.ToArray()))
.ToArray()));
})
.WithName("GetSeasonCategories")
.WithOpenApi();
app.MapGet("/api/public/seasons/{year:int}/winners", async (int year, AwardsDbContext db) =>
{
var items = await db.Results
.AsNoTracking()
.Include(result => result.Candidate)
.Where(result => result.Season.Year == year)
.OrderBy(result => result.CategoryName)
.Select(result => new WinnerArchiveItemDto(
result.CategoryName,
result.Candidate.DisplayName,
result.Candidate.ChannelSlug))
.ToArrayAsync();
return Results.Ok(new WinnerArchiveResponse(year, items));
})
.WithName("GetWinnerArchive")
.WithOpenApi();
app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominationRequest request, AwardsDbContext db) =>
{
if (request.Nominees.Length is 0 or > 3)
{
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." });
}
var distinctNominees = request.Nominees
.Select(item => item.Trim())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (distinctNominees.Length != request.Nominees.Length)
{
return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." });
}
var category = await db.Categories
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
if (category is null)
{
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 nominations." });
}
var records = distinctNominees.Select(name => new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
SubmittedByTwitchId = submitterId,
CandidateText = name,
CreatedAt = DateTimeOffset.UtcNow,
});
await db.Nominations.AddRangeAsync(records);
await db.SaveChangesAsync();
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name });
})
.WithName("CreateNomination")
.WithOpenApi();
app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest request, AwardsDbContext db) =>
{
if (request.Entries.Length == 0)
{
return Results.BadRequest(new { message = "At least one vote entry is required." });
}
var distinctCategoryCount = request.Entries
.Select(item => item.CategoryId)
.Distinct()
.Count();
if (distinctCategoryCount != request.Entries.Length)
{
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
}
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 votes." });
}
var ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
SubmittedAt = DateTimeOffset.UtcNow,
Status = "submitted",
};
ballot.Entries = request.Entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
await db.VoteBallots.AddAsync(ballot);
await db.SaveChangesAsync();
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count });
})
.WithName("CreateVote")
.WithOpenApi();
app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
if (currentSeason is null)
{
return Results.NotFound();
}
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id);
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id);
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null);
var topCategoryNames = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Ballot.SeasonId == currentSeason.Id)
.Select(item => item.Category.Name)
.ToListAsync();
var topCategories = topCategoryNames
.GroupBy(name => name)
.Select(group => new AdminTopCategoryDto(group.Key, group.Count()))
.OrderByDescending(item => item.Votes)
.Take(5)
.ToArray();
var response = new AdminDashboardResponse(
new[]
{
new AdminMetricDto("Nominierungen", nominationCount, "+12.4% vs. gestern"),
new AdminMetricDto("Votes", voteCount, "+8.7% vs. gestern"),
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
},
new[]
{
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."),
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."),
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
},
topCategories);
return Results.Ok(response);
})
.WithName("GetAdminDashboard")
.WithOpenApi();
app.MapGet("/api/admin/seasons", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var seasons = await db.Seasons
.AsNoTracking()
.OrderByDescending(item => item.Year)
.Select(item => new AdminSeasonListItemDto(
item.Id,
item.Year,
item.Name,
item.CurrentPhase,
item.IsCurrent,
item.Categories.Count))
.ToArrayAsync();
return Results.Ok(seasons);
})
.WithName("GetAdminSeasons")
.WithOpenApi();
app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var candidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.DisplayName)
.Select(item => new AdminCandidateItemDto(
item.Id,
item.CategoryId,
item.DisplayName,
item.ChannelSlug,
item.Platform))
.ToArrayAsync();
var candidateCounts = candidates
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => group.Count());
var categoryRows = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(category => new
{
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
})
.ToArrayAsync();
var categories = categoryRows
.Select(category => new AdminCategoryItemDto(
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
.ToArray();
var pendingNominations = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.CandidateText != null)
.OrderByDescending(item => item.CreatedAt)
.Take(20)
.Select(item => new AdminNominationReviewItemDto(
item.Id,
item.CategoryId,
item.Category.Name,
item.SubmittedByTwitchId,
item.CandidateText!,
item.CreatedAt))
.ToArrayAsync();
return Results.Ok(new AdminSeasonDetailResponse(
season.Id,
season.Year,
season.Name,
season.CurrentPhase,
season.IsCurrent,
categories,
candidates,
pendingNominations));
})
.WithName("GetAdminSeasonDetail")
.WithOpenApi();
app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, UpdateSeasonRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
season.CurrentPhase = request.CurrentPhase.Trim();
if (request.IsCurrent && !season.IsCurrent)
{
var activeSeasons = await db.Seasons.Where(item => item.IsCurrent && item.Id != seasonId).ToListAsync();
foreach (var activeSeason in activeSeasons)
{
activeSeason.IsCurrent = false;
}
}
season.IsCurrent = request.IsCurrent;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, seasonId = season.Id });
})
.WithName("UpdateAdminSeason")
.WithOpenApi();
app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext context, int seasonId, UpsertCategoryRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var category = new Category
{
SeasonId = seasonId,
GroupName = request.GroupName.Trim(),
Name = request.Name.Trim(),
Slug = request.Slug.Trim(),
Description = request.Description.Trim(),
SortOrder = request.SortOrder,
MaxNomineesPerUser = request.MaxNomineesPerUser,
};
db.Categories.Add(category);
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id });
})
.WithName("CreateAdminCategory")
.WithOpenApi();
app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, UpsertCategoryRequest request, 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();
}
category.GroupName = request.GroupName.Trim();
category.Name = request.Name.Trim();
category.Slug = request.Slug.Trim();
category.Description = request.Description.Trim();
category.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id });
})
.WithName("UpdateAdminCategory")
.WithOpenApi();
app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext context, int seasonId, UpsertCandidateRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId);
if (category is null)
{
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
}
var candidate = new Candidate
{
SeasonId = seasonId,
CategoryId = request.CategoryId,
DisplayName = request.DisplayName.Trim(),
ChannelSlug = request.ChannelSlug.Trim(),
Platform = request.Platform.Trim(),
};
db.Candidates.Add(candidate);
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id });
})
.WithName("CreateAdminCandidate")
.WithOpenApi();
app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, UpsertCandidateRequest request, 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();
}
candidate.CategoryId = request.CategoryId;
candidate.DisplayName = request.DisplayName.Trim();
candidate.ChannelSlug = request.ChannelSlug.Trim();
candidate.Platform = request.Platform.Trim();
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id });
})
.WithName("UpdateAdminCandidate")
.WithOpenApi();
app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var nomination = await db.Nominations
.Include(item => item.Category)
.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
? nomination.CandidateText
: request.DisplayName.Trim();
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 existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryId == nomination.CategoryId
&& item.DisplayName.ToLower() == rawDisplayName.ToLower());
var candidate = existingCandidate;
if (candidate is null)
{
candidate = new Candidate
{
SeasonId = nomination.SeasonId,
CategoryId = nomination.CategoryId,
DisplayName = rawDisplayName,
ChannelSlug = channelSlug,
Platform = platform,
};
db.Candidates.Add(candidate);
await db.SaveChangesAsync();
}
else
{
if (!string.IsNullOrWhiteSpace(channelSlug))
{
candidate.ChannelSlug = channelSlug;
}
if (!string.IsNullOrWhiteSpace(platform))
{
candidate.Platform = platform;
}
}
nomination.CandidateId = candidate.Id;
nomination.CandidateText = null;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
})
.WithName("ApproveAdminNomination")
.WithOpenApi();
app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpContext context, int nominationId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
nomination.CandidateText = null;
nomination.CandidateId = null;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
})
.WithName("RejectAdminNomination")
.WithOpenApi();
app.Run();