Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 441ef2b850 | |||
| c466348d18 | |||
| 1ab26ec2fd | |||
| b53c7fb736 | |||
| 4b2c5fa15d | |||
| fc5c13a4fd | |||
| 18b61bed52 |
+31
@@ -3,7 +3,38 @@ frontend/dist/
|
||||
Backend/bin/
|
||||
Backend/obj/
|
||||
.DS_Store
|
||||
|
||||
# Editor and local machine state
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.template
|
||||
*.local
|
||||
*.secret
|
||||
*.secrets
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# Logs and diagnostics
|
||||
*.log
|
||||
logs/
|
||||
log/
|
||||
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.rar
|
||||
*.7z
|
||||
*.docx
|
||||
|
||||
# Local Claude Code config (settings, preview launch configs)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Read First
|
||||
|
||||
Before changing code or durable documentation, inspect the current repository
|
||||
state and read the docs that match the task:
|
||||
|
||||
- `docs/PROJECT.md` for project intent, runtime facts, environments and docs map.
|
||||
- `docs/ARCHITECTURE.md` for system boundaries, source-of-truth and deployment flow.
|
||||
- `docs/CONVENTIONS.md` for coding, validation, review and documentation standards.
|
||||
- `docs/DECISIONS.md` for accepted architecture and operations trade-offs.
|
||||
- `docs/CHECKLISTS.md` for task-specific quality gates.
|
||||
- `DESIGN.md` for UI, visual language, admin/public layout and responsive rules.
|
||||
|
||||
Treat these files as the durable starter-kit structure for this existing
|
||||
project. Preserve existing project-specific docs and merge improvements instead
|
||||
of replacing them with generic templates.
|
||||
|
||||
## Confidence Gate
|
||||
|
||||
If requirements are below roughly 95% clear, ask concise clarifying questions
|
||||
before implementing. If ambiguity affects data loss, security, authentication,
|
||||
authorization, public behavior, migrations, deployment or irreversible changes,
|
||||
stop and ask.
|
||||
|
||||
If ambiguity is isolated and low risk, make the smallest reasonable assumption
|
||||
and state it in the final summary.
|
||||
|
||||
## Execution Philosophy
|
||||
|
||||
Your objective is not to generate code as quickly as possible.
|
||||
|
||||
Your objective is to solve engineering problems with the judgment of an experienced senior software engineer.
|
||||
|
||||
Always determine the most appropriate execution strategy before writing code.
|
||||
|
||||
For every task, first decide:
|
||||
|
||||
- Does this require deeper reasoning?
|
||||
- Can the work be decomposed?
|
||||
- Can independent parts be executed in parallel?
|
||||
- Would delegated agents improve efficiency?
|
||||
- Is additional clarification required?
|
||||
|
||||
Choose the execution strategy that maximizes correctness, maintainability, and cost efficiency.
|
||||
|
||||
Treat delegation, planning, and implementation as engineering decisions rather than fixed rules.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- `frontend/` contains the Vue 3/Vite app. Main source lives in `frontend/src/`, with views in `views/`, reusable UI in `components/`, stores in `stores/`, API helpers in `lib/` and `lib/api/`, and static assets in `assets/` or `public/`.
|
||||
- `Backend/` contains the ASP.NET Core 8 API. Domain models are in `Domain/`, EF Core setup and migrations in `Data/` and `Migrations/`, HTTP endpoints in `Endpoints/`, contracts in `Contracts/`, and shared services/repositories in `Services/` and `Repositories/`.
|
||||
- `.gitea/workflows/ci.yaml` defines build, hygiene, deploy, and live verification.
|
||||
- `docs/` holds planning and workflow notes.
|
||||
|
||||
Do not commit generated output such as `frontend/dist`, `Backend/bin`, `Backend/obj`, archives, prototype exports, or handoff documents.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Start the local database:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
Run the backend:
|
||||
|
||||
```bash
|
||||
cd Backend
|
||||
dotnet restore
|
||||
dotnet ef database update
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet run --urls http://127.0.0.1:5084
|
||||
```
|
||||
|
||||
Run the frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Validate before pushing:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
cd .. && dotnet build Backend/Backend.csproj --configuration Release
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`npm run build` runs `vue-tsc -b` and `vite build`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use TypeScript with Vue single-file components. Keep `.vue` files focused; split large admin or workflow screens into smaller components/composables. Name Vue components in `PascalCase.vue`, composables as `useThing.ts`, and API helpers by feature. Backend code uses nullable-enabled C# with implicit usings; align endpoint, contract, service, and repository names by feature.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
There is no dedicated test project checked in yet. Treat the frontend build, backend Release build, CI hygiene checks, and relevant manual endpoint/browser verification as required validation. Add future .NET tests in a separate test project and frontend tests near the feature they cover.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Recent history uses short imperative subjects, for example `Fix team profile auth recovery` or `Refine admin risk workspace`. Keep commits scoped and describe the user-visible behavior or operational change.
|
||||
|
||||
Pull requests should include a summary, validation commands run, linked issue or context when available, screenshots for UI changes, and notes for database migrations, deployment risk, or configuration changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Use `Backend/appsettings.Development.json` only for local defaults. Non-local environments should provide `VTSA_POSTGRES` or `ConnectionStrings__Postgres`. Never hardcode secrets, tokens, API keys, or production credentials in `Backend/` or `frontend/src/`; CI scans these paths.
|
||||
|
||||
## Task Execution Strategy
|
||||
|
||||
## Task Classification
|
||||
|
||||
Before beginning any work, classify the request based on the amount of reasoning required.
|
||||
|
||||
### Simple
|
||||
|
||||
Small, isolated tasks with minimal reasoning.
|
||||
|
||||
Examples:
|
||||
|
||||
- formatting
|
||||
- typo fixes
|
||||
- documentation
|
||||
- repository searches
|
||||
- updating comments
|
||||
- locating references
|
||||
- simple bug fixes
|
||||
- small refactorings
|
||||
- straightforward unit tests
|
||||
- boilerplate generation
|
||||
- dependency lookups
|
||||
|
||||
Prefer delegation to faster, lower-cost agents when available.
|
||||
|
||||
---
|
||||
|
||||
### Moderate
|
||||
|
||||
Tasks requiring understanding of multiple files or components.
|
||||
|
||||
Examples:
|
||||
|
||||
- implementing a feature
|
||||
- extending existing functionality
|
||||
- API endpoints
|
||||
- service implementations
|
||||
- medium-sized refactoring
|
||||
|
||||
Delegate independent supporting work where beneficial while keeping overall coordination in the primary reasoning process.
|
||||
|
||||
---
|
||||
|
||||
### Complex
|
||||
|
||||
Tasks requiring significant reasoning or architectural understanding.
|
||||
|
||||
Examples:
|
||||
|
||||
- architecture
|
||||
- authentication
|
||||
- authorization
|
||||
- security
|
||||
- database design
|
||||
- distributed systems
|
||||
- major refactoring
|
||||
- performance-critical systems
|
||||
- cross-module changes
|
||||
|
||||
The primary reasoning process should remain responsible.
|
||||
|
||||
Delegate only isolated supporting tasks.
|
||||
|
||||
---
|
||||
|
||||
## Intelligent Task Delegation
|
||||
|
||||
Continuously evaluate whether the current task should be handled entirely by the primary reasoning process or decomposed into smaller independent tasks.
|
||||
|
||||
When delegated agents are available:
|
||||
|
||||
- automatically identify independent subtasks
|
||||
- delegate low-risk work to faster and lower-cost agents
|
||||
- keep architectural decisions within the primary reasoning process
|
||||
- merge delegated work only after validating correctness
|
||||
|
||||
Do not ask for permission before delegating unless delegation could affect correctness, security, or architecture.
|
||||
|
||||
Suitable delegated work includes:
|
||||
|
||||
- searching the repository
|
||||
- finding references
|
||||
- documentation updates
|
||||
- dependency analysis
|
||||
- duplicate code detection
|
||||
- code formatting
|
||||
- renaming symbols
|
||||
- generating boilerplate
|
||||
- simple implementations
|
||||
- isolated unit tests
|
||||
- isolated bug fixes
|
||||
|
||||
Keep these tasks in the primary reasoning process:
|
||||
|
||||
- architecture decisions
|
||||
- business logic
|
||||
- security-sensitive code
|
||||
- API design
|
||||
- database design
|
||||
- system integration
|
||||
- cross-module refactoring
|
||||
- final implementation review
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution
|
||||
|
||||
Whenever independent work can safely execute in parallel:
|
||||
|
||||
- identify parallelizable subtasks
|
||||
- execute them concurrently using delegated agents when available
|
||||
- validate all results before integration
|
||||
- ensure consistency before presenting the final solution
|
||||
|
||||
Prefer parallel execution whenever it improves efficiency without compromising correctness.
|
||||
|
||||
---
|
||||
|
||||
## Delegation Principles
|
||||
|
||||
Optimize for the following priorities:
|
||||
|
||||
1. Correctness
|
||||
2. Engineering quality
|
||||
3. Maintainability
|
||||
4. Cost efficiency
|
||||
5. Execution speed
|
||||
|
||||
Use delegated agents only when they improve efficiency without reducing solution quality.
|
||||
|
||||
Always keep final responsibility, integration, validation, and architectural reasoning within the primary reasoning process.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A task is complete when:
|
||||
|
||||
- the requested behavior or documentation exists;
|
||||
- changes are consistent with `docs/ARCHITECTURE.md` and `docs/CONVENTIONS.md`;
|
||||
- relevant builds, checks or manual validation have been run;
|
||||
- documentation is updated when setup, architecture, operations or public
|
||||
behavior changed;
|
||||
- the diff is reviewed for unrelated changes, secrets, generated output and
|
||||
accidental overwrites;
|
||||
- remaining risks or skipped validation are clearly communicated.
|
||||
@@ -2,7 +2,6 @@ VTSA_POSTGRES=Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=
|
||||
ConnectionStrings__Postgres=Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=vtsa_dev;Password=change-me-local-only
|
||||
Frontend__AllowedOrigins__0=http://localhost:5173
|
||||
Frontend__AllowedOrigins__1=http://127.0.0.1:5173
|
||||
VTSA_SEED_MODE=demo
|
||||
VTSA_DEMO_LOGIN_ENABLED=true
|
||||
VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin
|
||||
VTSA_DEMO_ADMIN_EMAIL=admin@example.local
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Backend.Contracts;
|
||||
using Backend.Domain;
|
||||
using System.Net;
|
||||
|
||||
namespace Backend.Common;
|
||||
|
||||
public static class SeasonMappings
|
||||
{
|
||||
private static readonly Regex HtmlBreakRegex = new(@"<\s*br\s*/?>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex HtmlListItemOpenRegex = new(@"<\s*li\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex HtmlBlockCloseRegex = new(@"</\s*(p|div|li|ul|ol|h1|h2|h3|h4|h5|h6)\s*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex HtmlTagRegex = new(@"<[^>]*>", RegexOptions.Compiled);
|
||||
private static readonly Regex MultiNewlineRegex = new(@"\n{3,}", RegexOptions.Compiled);
|
||||
|
||||
public static bool IsSeasonScheduleValid(
|
||||
DateOnly nominationStartsAt,
|
||||
DateOnly nominationEndsAt,
|
||||
@@ -90,6 +98,28 @@ public static class SeasonMappings
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? "https://twitch.tv/jayuhime" : trimmed;
|
||||
}
|
||||
|
||||
public static string NormalizePlainTextContent(string? value)
|
||||
{
|
||||
var trimmed = value?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var withBreakHints = HtmlBreakRegex.Replace(trimmed, "\n");
|
||||
withBreakHints = HtmlListItemOpenRegex.Replace(withBreakHints, "- ");
|
||||
withBreakHints = HtmlBlockCloseRegex.Replace(withBreakHints, "\n");
|
||||
withBreakHints = HtmlTagRegex.Replace(withBreakHints, " ");
|
||||
withBreakHints = WebUtility.HtmlDecode(withBreakHints).Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
|
||||
var normalizedLines = withBreakHints
|
||||
.Split('\n')
|
||||
.Select(line => line.Trim())
|
||||
.ToArray();
|
||||
|
||||
return MultiNewlineRegex.Replace(string.Join('\n', normalizedLines), "\n\n").Trim();
|
||||
}
|
||||
|
||||
public static string NormalizePhaseKey(string? currentPhase)
|
||||
{
|
||||
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
@@ -183,6 +213,7 @@ public static class SeasonMappings
|
||||
[
|
||||
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
|
||||
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
|
||||
new FooterLinkDto("sponsors", "Sponsoren & Partner", settings.SponsorsUrl, settings.SponsorsContent),
|
||||
new FooterLinkDto("sponsors", "Sponsoren & Partner", string.Empty, settings.SponsorsContent),
|
||||
new FooterLinkDto("showacts", "Showacts", settings.ShowactsUrl, settings.ShowactsContent),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Common;
|
||||
|
||||
public static class ShowactApplicationSchedule
|
||||
{
|
||||
public static string? Validate(DateOnly? startsAt, DateOnly? endsAt)
|
||||
{
|
||||
if (startsAt.HasValue && endsAt.HasValue && startsAt.Value > endsAt.Value)
|
||||
{
|
||||
return "Der Showact-Zeitraum ist ungueltig. Der Start darf nicht nach der Deadline liegen.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsOpenNow(SiteSettings settings, DateOnly today)
|
||||
{
|
||||
if (!settings.ShowactApplicationsEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settings.ShowactApplicationStartsAt.HasValue && today < settings.ShowactApplicationStartsAt.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settings.ShowactApplicationEndsAt.HasValue && today > settings.ShowactApplicationEndsAt.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,13 @@ public sealed record AdminMetricDto(string Label, int Value, string Note);
|
||||
|
||||
public sealed record AdminActivityDto(string Label, string Age);
|
||||
|
||||
public sealed record AdminTopCategoryDto(string Category, int Votes);
|
||||
public sealed record AdminTopCategoryDto(string Category, int Value, string Basis);
|
||||
|
||||
public sealed record AdminDashboardResponse(
|
||||
int SeasonId,
|
||||
int Year,
|
||||
string SeasonName,
|
||||
bool IsCurrent,
|
||||
IEnumerable<AdminMetricDto> Metrics,
|
||||
IEnumerable<AdminActivityDto> Activities,
|
||||
IEnumerable<AdminTopCategoryDto> TopCategories,
|
||||
|
||||
@@ -55,11 +55,27 @@ public sealed record AdminAuditEntriesResponse(
|
||||
|
||||
public sealed record AdminNominationReviewItemDto(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string CategoryGroupName,
|
||||
string CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
bool RequiresManualReview,
|
||||
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
@@ -68,6 +84,32 @@ public sealed record AdminNominationReviewItemDto(
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
public sealed record AdminNominationReviewGroupDto(
|
||||
int Id,
|
||||
int[] NominationIds,
|
||||
string CategoryGroupName,
|
||||
string DisplayName,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
bool RequiresManualReview,
|
||||
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
int NominationTally,
|
||||
int UniqueSubmitterCount,
|
||||
DateTimeOffset FirstSubmittedAt,
|
||||
DateTimeOffset LastSubmittedAt);
|
||||
|
||||
public sealed record AdminClipSubmissionItemDto(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
@@ -87,10 +129,25 @@ public sealed record ApproveNominationRequest(
|
||||
string? DisplayName,
|
||||
string? ChannelSlug,
|
||||
string? Platform,
|
||||
int? CategoryId,
|
||||
string? ReviewNote);
|
||||
|
||||
public sealed record RejectNominationRequest(string? ReviewNote);
|
||||
|
||||
public sealed record ReopenRejectedNominationRequest(string? ReviewNote);
|
||||
|
||||
public sealed record UpdateNominationTrackingReviewRequest(
|
||||
string Status,
|
||||
string? ReviewNote);
|
||||
|
||||
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
|
||||
|
||||
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
|
||||
|
||||
public sealed record UpdateNominationLinkBlacklistRequest(string[] Urls);
|
||||
|
||||
public sealed record AddNominationLinkBlacklistEntryRequest(string Url);
|
||||
|
||||
public sealed record UpdateClipStatusRequest(
|
||||
string Status,
|
||||
string? ReviewNote);
|
||||
@@ -116,3 +173,36 @@ public sealed record AdminRiskRuleDto(
|
||||
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
|
||||
|
||||
public sealed record UpdateRiskRulesRequest(AdminRiskRuleDto[] Rules);
|
||||
|
||||
public sealed record AdminWorkflowRuleDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
int Limit,
|
||||
string Mode,
|
||||
string Description);
|
||||
|
||||
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
|
||||
|
||||
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
|
||||
|
||||
public sealed record AdminTrackingFlagHitDto(
|
||||
string Key,
|
||||
string Label,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record AdminTrackingMetricStateDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Required,
|
||||
string SourceSupport,
|
||||
bool Present,
|
||||
string Value,
|
||||
string Description,
|
||||
string WindowKey,
|
||||
string WindowLabel,
|
||||
bool AutoWindowSupported);
|
||||
|
||||
@@ -6,7 +6,10 @@ public sealed record AdminSeasonListItemDto(
|
||||
string Name,
|
||||
string CurrentPhase,
|
||||
bool IsCurrent,
|
||||
int CategoryCount);
|
||||
bool IsDemo,
|
||||
int CategoryCount,
|
||||
DateTimeOffset? WinnersPublishedAt,
|
||||
string? WinnersPublishedByTwitchId);
|
||||
|
||||
public sealed record AdminCategoryItemDto(
|
||||
int Id,
|
||||
@@ -16,29 +19,97 @@ public sealed record AdminCategoryItemDto(
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax,
|
||||
int CandidateCount);
|
||||
|
||||
public sealed record AdminSubcategoryTemplateDto(
|
||||
string Name,
|
||||
string Slug,
|
||||
int SortOrder,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public sealed record AdminCandidateItemDto(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string Platform);
|
||||
string Platform,
|
||||
int NominationTally,
|
||||
string AcceptanceStatus,
|
||||
string? AcceptanceNote,
|
||||
string? ClipCompilationUrl,
|
||||
string? ClipCompilationTitle,
|
||||
string? ClipCompilationPlatform,
|
||||
string ClipEmbedStatus);
|
||||
|
||||
public sealed record AdminAwardResultItemDto(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
string CategoryName,
|
||||
int CandidateId,
|
||||
int? StreamerIdentityId,
|
||||
string CandidateDisplayName,
|
||||
string CandidateChannelSlug,
|
||||
string CandidatePlatform);
|
||||
|
||||
public sealed record AdminVotingWorkspaceSummaryDto(
|
||||
int TotalVotes,
|
||||
int TotalBallots,
|
||||
int TotalSubcategories,
|
||||
int VotedSubcategories,
|
||||
int ReadySubcategories,
|
||||
int ProblemSubcategories,
|
||||
int WinnerSetSubcategories);
|
||||
|
||||
public sealed record AdminVotingCandidateRankDto(
|
||||
int CandidateId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string Platform,
|
||||
int Votes,
|
||||
int VoteSharePercent,
|
||||
int NominationTally,
|
||||
bool HasClip,
|
||||
string ClipEmbedStatus,
|
||||
bool HasWinnerConflict,
|
||||
bool IsCurrentWinner,
|
||||
bool IsAccepted,
|
||||
bool IsTopTie);
|
||||
|
||||
public sealed record AdminVotingCategoryWorkspaceItemDto(
|
||||
int CategoryId,
|
||||
string GroupName,
|
||||
string CategoryName,
|
||||
int SortOrder,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax,
|
||||
int VoteCount,
|
||||
int BallotCount,
|
||||
int CandidateCount,
|
||||
int ReadyCandidateCount,
|
||||
int NominationCount,
|
||||
int OpenReviewCount,
|
||||
bool HasWinner,
|
||||
bool WinnerReady,
|
||||
bool HasTopVoteTie,
|
||||
bool HasMissingClip,
|
||||
bool HasRuleConflict,
|
||||
bool HasOpenReviews,
|
||||
bool HasSoftNominatorWarning,
|
||||
IEnumerable<AdminVotingCandidateRankDto> Leaderboard);
|
||||
|
||||
public sealed record AdminVotingWorkspaceDto(
|
||||
AdminVotingWorkspaceSummaryDto Summary,
|
||||
IEnumerable<AdminVotingCategoryWorkspaceItemDto> Categories);
|
||||
|
||||
public sealed record AdminSeasonDetailResponse(
|
||||
int Id,
|
||||
int Year,
|
||||
string Name,
|
||||
string ShowStreamUrl,
|
||||
bool IsDemo,
|
||||
string CurrentPhase,
|
||||
bool IsCurrent,
|
||||
bool IsCommunityOnly,
|
||||
@@ -50,17 +121,23 @@ public sealed record AdminSeasonDetailResponse(
|
||||
DateOnly ReviewEndsAt,
|
||||
DateOnly ShowDate,
|
||||
TimeOnly ShowStartsAt,
|
||||
DateTimeOffset? WinnersPublishedAt,
|
||||
string? WinnersPublishedByTwitchId,
|
||||
IEnumerable<AdminSubcategoryTemplateDto> SubcategoryTemplates,
|
||||
IEnumerable<AdminCategoryItemDto> Categories,
|
||||
IEnumerable<AdminCandidateItemDto> Candidates,
|
||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
||||
IEnumerable<AdminNominationReviewGroupDto> PendingNominationGroups,
|
||||
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
||||
string TrackingReviewNotes,
|
||||
bool ShowTrackingReviewNotes,
|
||||
IEnumerable<AdminAwardResultItemDto> Results,
|
||||
AdminVotingWorkspaceDto VotingWorkspace,
|
||||
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
||||
|
||||
public sealed record CreateSeasonRequest(
|
||||
int Year,
|
||||
string Name,
|
||||
string ShowStreamUrl,
|
||||
string CurrentPhase,
|
||||
bool IsCurrent,
|
||||
bool IsCommunityOnly,
|
||||
@@ -77,7 +154,6 @@ public sealed record CreateSeasonRequest(
|
||||
public sealed record UpdateSeasonRequest(
|
||||
int Year,
|
||||
string Name,
|
||||
string ShowStreamUrl,
|
||||
string CurrentPhase,
|
||||
bool IsCurrent,
|
||||
bool IsCommunityOnly,
|
||||
@@ -96,13 +172,30 @@ public sealed record UpsertCategoryRequest(
|
||||
string Slug,
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public sealed record UpdateSeasonSubcategoryTemplatesRequest(
|
||||
AdminSubcategoryTemplateDto[] Templates);
|
||||
|
||||
public sealed record UpsertCategoryGroupRequest(
|
||||
string GroupName,
|
||||
string Description,
|
||||
int SortOrder,
|
||||
int MaxNomineesPerUser);
|
||||
|
||||
public sealed record UpsertCandidateRequest(
|
||||
int CategoryId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string Platform);
|
||||
string Platform,
|
||||
string? AcceptanceStatus = null,
|
||||
string? AcceptanceNote = null,
|
||||
string? ClipCompilationUrl = null,
|
||||
string? ClipCompilationTitle = null,
|
||||
string? ClipCompilationPlatform = null,
|
||||
string? ClipEmbedStatus = null);
|
||||
|
||||
public sealed record SetAwardResultRequest(
|
||||
int CategoryId,
|
||||
|
||||
@@ -4,6 +4,8 @@ public sealed record AdminSiteSettingsResponse(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
string? PrivacyPolicyUpdatedBy,
|
||||
@@ -14,13 +16,34 @@ public sealed record AdminSiteSettingsResponse(
|
||||
string ContactContent,
|
||||
string SponsorsUrl,
|
||||
string SponsorsContent,
|
||||
string ShowactsUrl,
|
||||
string ShowactsContent,
|
||||
string StreamBannerEyebrow,
|
||||
string StreamBannerTitle,
|
||||
string StreamBannerText,
|
||||
string StreamBannerLiveButtonLabel,
|
||||
string StreamBannerLiveButtonUrl,
|
||||
string StreamBannerLockedButtonLabel,
|
||||
bool StreamBannerUseCompletedContent,
|
||||
string StreamBannerCompletedEyebrow,
|
||||
string StreamBannerCompletedTitle,
|
||||
string StreamBannerCompletedText,
|
||||
string StreamBannerCompletedButtonLabel,
|
||||
string StreamBannerCompletedButtonUrl,
|
||||
string AwardsSectionTitle,
|
||||
string AwardsSectionDescription,
|
||||
string SubcategoriesSectionTitle,
|
||||
string SubcategoriesSectionDescription,
|
||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||
IEnumerable<FaqItemDto> Faq);
|
||||
IEnumerable<FaqItemDto> Faq,
|
||||
string ShowactFormSchemaJson);
|
||||
|
||||
public sealed record UpdateSiteSettingsRequest(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
string ImprintUrl,
|
||||
@@ -29,8 +52,27 @@ public sealed record UpdateSiteSettingsRequest(
|
||||
string ContactContent,
|
||||
string SponsorsUrl,
|
||||
string SponsorsContent,
|
||||
string ShowactsUrl,
|
||||
string ShowactsContent,
|
||||
string StreamBannerEyebrow,
|
||||
string StreamBannerTitle,
|
||||
string StreamBannerText,
|
||||
string StreamBannerLiveButtonLabel,
|
||||
string StreamBannerLiveButtonUrl,
|
||||
string StreamBannerLockedButtonLabel,
|
||||
bool StreamBannerUseCompletedContent,
|
||||
string StreamBannerCompletedEyebrow,
|
||||
string StreamBannerCompletedTitle,
|
||||
string StreamBannerCompletedText,
|
||||
string StreamBannerCompletedButtonLabel,
|
||||
string StreamBannerCompletedButtonUrl,
|
||||
string AwardsSectionTitle,
|
||||
string AwardsSectionDescription,
|
||||
string SubcategoriesSectionTitle,
|
||||
string SubcategoriesSectionDescription,
|
||||
PublicSocialLinkDto[] SocialLinks,
|
||||
FaqItemDto[] Faq);
|
||||
FaqItemDto[] Faq,
|
||||
string? ShowactFormSchemaJson = null);
|
||||
|
||||
public sealed record AdminOperationalSettingsResponse(
|
||||
bool DemoLoginManagedByDatabase,
|
||||
@@ -45,10 +87,34 @@ public sealed record AdminOperationalSettingsResponse(
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
|
||||
public sealed record AdminOptionalFeatureSettingsResponse(
|
||||
bool ClipSubmissionsEnabled,
|
||||
bool ClipReviewEnabled,
|
||||
bool ClipAdminMenuVisible,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
bool ShowactApplicationsOpenNow,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
|
||||
public sealed record UpdateOptionalFeatureSettingsRequest(
|
||||
bool ClipSubmissionsEnabled,
|
||||
bool ClipReviewEnabled,
|
||||
bool ClipAdminMenuVisible,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible);
|
||||
|
||||
public sealed record UpdateOperationalSettingsRequest(
|
||||
bool DemoLoginEnabled,
|
||||
string DemoLoginEmail,
|
||||
@@ -59,6 +125,71 @@ public sealed record UpdateOperationalSettingsRequest(
|
||||
string? TwitchClientSecret,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
|
||||
public sealed record AdminTrackingSourceDto(
|
||||
string ProviderKey,
|
||||
string ProviderLabel,
|
||||
string BaseUrl,
|
||||
string NotesSummary,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
public sealed record AdminTrackingMetricRuleDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string SourceSupport,
|
||||
string Description,
|
||||
bool RequiredForAutoClassification,
|
||||
bool ShowInReview,
|
||||
bool ShowInAdminSummary,
|
||||
bool ManualOverrideAllowed,
|
||||
string WindowKey,
|
||||
string[] AutoSupportedWindowKeys,
|
||||
string? ProviderFieldKey,
|
||||
int? TopCount,
|
||||
int? MinPrimaryCategorySharePercent,
|
||||
int? MinPrimaryCategoryHours,
|
||||
int? MaxDistinctCategoriesBeforeFlag,
|
||||
string[] IgnoredCategories,
|
||||
bool MatchAwardCategoryAgainstTopCategories,
|
||||
bool FlagIfAwardCategoryNotInTopX,
|
||||
bool FlagIfCategorySpreadTooWide,
|
||||
bool FlagIfNoCategoryContextAvailable,
|
||||
int? MinValue,
|
||||
int? MaxValue);
|
||||
|
||||
public sealed record AdminTrackingFlagRuleDto(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool AutoTriggerEnabled,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record AdminTrackingRulesResponse(
|
||||
AdminTrackingSourceDto Source,
|
||||
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||
AdminTrackingFlagRuleDto[] Flags,
|
||||
string ManualReviewNotes);
|
||||
|
||||
public sealed record UpdateTrackingRulesRequest(
|
||||
AdminTrackingSourceDto Source,
|
||||
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||
AdminTrackingFlagRuleDto[] Flags,
|
||||
string ManualReviewNotes);
|
||||
|
||||
public sealed record UpdateTrackingSourceRequest(
|
||||
AdminTrackingSourceDto Source);
|
||||
|
||||
public sealed record UpdateTrackingReviewNotesRequest(
|
||||
string ManualReviewNotes,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed record AdminTeamPermissionDto(
|
||||
string Key,
|
||||
string Label,
|
||||
string Description,
|
||||
string GroupLabel,
|
||||
string MenuPath,
|
||||
bool ReadOnlySupported);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed record AuthSessionDto(
|
||||
string DisplayName,
|
||||
string Role,
|
||||
IEnumerable<string> PermissionKeys,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MustChangePassword = false,
|
||||
string? TeamLogin = null,
|
||||
string? BoundTwitchUserId = null,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace Backend.Contracts;
|
||||
|
||||
public sealed record SponsorDto(
|
||||
int Id,
|
||||
int SeasonId,
|
||||
string Name,
|
||||
string WebsiteUrl,
|
||||
string LogoUrl,
|
||||
string Description,
|
||||
string Tier,
|
||||
int SortOrder,
|
||||
bool IsVisible);
|
||||
|
||||
public sealed record PublicSponsorsResponse(int Year, SponsorDto[] Items);
|
||||
|
||||
public sealed record UpsertSponsorRequest(
|
||||
string Name,
|
||||
string WebsiteUrl,
|
||||
string LogoUrl,
|
||||
string Description,
|
||||
string Tier,
|
||||
int SortOrder,
|
||||
bool IsVisible);
|
||||
|
||||
public sealed record ShowactApplicationDto(
|
||||
int Id,
|
||||
int SeasonId,
|
||||
string ArtistName,
|
||||
string ContactEmail,
|
||||
string ContactDiscord,
|
||||
string PlatformUrl,
|
||||
string PerformanceType,
|
||||
string Description,
|
||||
string TechnicalNotes,
|
||||
string ReferenceUrl,
|
||||
string Status,
|
||||
string? ReviewNote,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ReviewedAt,
|
||||
string FieldResponsesJson);
|
||||
|
||||
public sealed record CreateShowactApplicationRequest(
|
||||
string? ArtistName = null,
|
||||
string? ContactEmail = null,
|
||||
string? ContactDiscord = null,
|
||||
string? PlatformUrl = null,
|
||||
string? PerformanceType = null,
|
||||
string? Description = null,
|
||||
string? TechnicalNotes = null,
|
||||
string? ReferenceUrl = null,
|
||||
string? FieldResponsesJson = null);
|
||||
|
||||
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
|
||||
@@ -16,11 +16,16 @@ public sealed record FeaturedCategoryDto(
|
||||
|
||||
public sealed record WinnerPreviewDto(
|
||||
int Year,
|
||||
string CategoryGroup,
|
||||
string Category,
|
||||
string WinnerName,
|
||||
string WinnerSlug,
|
||||
string WinnerPlatform,
|
||||
string WinnerUrl);
|
||||
string WinnerUrl,
|
||||
string? ClipUrl,
|
||||
string? ClipTitle,
|
||||
string? ClipPlatform,
|
||||
string? ClipEmbedStatus);
|
||||
|
||||
public sealed record ArchiveYearDto(
|
||||
int Year,
|
||||
@@ -42,12 +47,33 @@ public sealed record FooterLinkDto(
|
||||
string Url,
|
||||
string Content);
|
||||
|
||||
public sealed record PublicStreamBannerContentDto(
|
||||
string Eyebrow,
|
||||
string Title,
|
||||
string Text,
|
||||
string LiveButtonLabel,
|
||||
string LiveButtonUrl,
|
||||
string LockedButtonLabel,
|
||||
bool UseCompletedContent,
|
||||
string CompletedEyebrow,
|
||||
string CompletedTitle,
|
||||
string CompletedText,
|
||||
string CompletedButtonLabel,
|
||||
string CompletedButtonUrl);
|
||||
|
||||
public sealed record PublicSiteContentDto(
|
||||
string HostDisplayName,
|
||||
string HostTagline,
|
||||
string NewsletterUrl,
|
||||
string ShareXUrl,
|
||||
string ShareDiscordUrl,
|
||||
string PrivacyEmail,
|
||||
string PrivacyPolicyContent,
|
||||
string AwardsSectionTitle,
|
||||
string AwardsSectionDescription,
|
||||
string SubcategoriesSectionTitle,
|
||||
string SubcategoriesSectionDescription,
|
||||
PublicStreamBannerContentDto StreamBanner,
|
||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||
IEnumerable<FooterLinkDto> FooterLinks);
|
||||
|
||||
@@ -57,13 +83,23 @@ public sealed record PublicSiteStatusResponse(
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
|
||||
public sealed record PublicFeatureFlagsDto(
|
||||
bool ClipSubmissionsEnabled,
|
||||
bool ClipReviewEnabled,
|
||||
string ClipSubmissionDisabledMessage,
|
||||
bool ShowactApplicationsEnabled,
|
||||
DateOnly? ShowactApplicationStartsAt,
|
||||
DateOnly? ShowactApplicationEndsAt,
|
||||
string ShowactApplicationDisabledMessage,
|
||||
bool SponsorsVisible,
|
||||
string ShowactFormSchemaJson);
|
||||
|
||||
public sealed record OverviewResponse(
|
||||
int SeasonId,
|
||||
int Year,
|
||||
string Title,
|
||||
DateOnly ShowDate,
|
||||
TimeOnly ShowStartsAt,
|
||||
string ShowStreamUrl,
|
||||
string CurrentPhase,
|
||||
bool IsCommunityOnly,
|
||||
string LoginProvider,
|
||||
@@ -72,4 +108,5 @@ public sealed record OverviewResponse(
|
||||
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
||||
IEnumerable<ArchiveYearDto> ArchiveYears,
|
||||
PublicSiteContentDto SiteContent,
|
||||
PublicFeatureFlagsDto FeatureFlags,
|
||||
IEnumerable<FaqItemDto> Faq);
|
||||
|
||||
@@ -4,10 +4,12 @@ public sealed record CandidateSummaryDto(
|
||||
int Id,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string ChannelUrl,
|
||||
string Platform,
|
||||
string? ClipUrl,
|
||||
string? ClipTitle,
|
||||
string? ClipPlatform);
|
||||
string? ClipPlatform,
|
||||
string? ClipEmbedStatus);
|
||||
|
||||
public sealed record PublicCategoryDetailDto(
|
||||
int Id,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Backend.Contracts;
|
||||
|
||||
public sealed record UserNominationStateDto(
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string CategoryGroupName,
|
||||
string[] Nominees);
|
||||
|
||||
public sealed record UserVoteStateDto(
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
namespace Backend.Contracts;
|
||||
|
||||
public sealed record WinnerArchiveItemDto(
|
||||
string CategoryGroup,
|
||||
string Category,
|
||||
string WinnerName,
|
||||
string WinnerSlug,
|
||||
string WinnerPlatform,
|
||||
string WinnerUrl);
|
||||
string WinnerUrl,
|
||||
string? ClipUrl,
|
||||
string? ClipTitle,
|
||||
string? ClipPlatform,
|
||||
string? ClipEmbedStatus);
|
||||
|
||||
public sealed record WinnerArchiveResponse(
|
||||
int Year,
|
||||
|
||||
@@ -6,7 +6,8 @@ public sealed record NominationEntryRequest(
|
||||
|
||||
public sealed record CreateNominationRequest(
|
||||
int Year,
|
||||
int CategoryId,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string TwitchUserId,
|
||||
string[]? Nominees,
|
||||
NominationEntryRequest[]? Nominations);
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
public DbSet<Season> Seasons => Set<Season>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<Candidate> Candidates => Set<Candidate>();
|
||||
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
|
||||
public DbSet<AwardResult> Results => Set<AwardResult>();
|
||||
public DbSet<Nomination> Nominations => Set<Nomination>();
|
||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||
@@ -16,6 +17,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
|
||||
public DbSet<ShowactApplication> ShowactApplications => Set<ShowactApplication>();
|
||||
public DbSet<Sponsor> Sponsors => Set<Sponsor>();
|
||||
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
|
||||
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
|
||||
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
|
||||
@@ -26,8 +29,11 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
{
|
||||
entity.HasIndex(item => item.Year).IsUnique();
|
||||
entity.Property(item => item.Name).HasMaxLength(160);
|
||||
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.IsDemo).HasDefaultValue(false);
|
||||
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
|
||||
entity.Property(item => item.WinnersPublishedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SiteSettings>(entity =>
|
||||
@@ -40,6 +46,17 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.ImprintUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.ContactUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.SponsorsUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.ShowactsUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.ShowactsContent).HasDefaultValue(string.Empty);
|
||||
entity.Property(item => item.StreamBannerEyebrow).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamBannerTitle).HasMaxLength(160);
|
||||
entity.Property(item => item.StreamBannerLiveButtonLabel).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamBannerLiveButtonUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.StreamBannerLockedButtonLabel).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamBannerCompletedEyebrow).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamBannerCompletedTitle).HasMaxLength(160);
|
||||
entity.Property(item => item.StreamBannerCompletedButtonLabel).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamBannerCompletedButtonUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.DemoLoginEmail).HasMaxLength(180);
|
||||
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
|
||||
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
|
||||
@@ -49,8 +66,21 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
||||
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
||||
entity.Property(item => item.TwitchScope).HasMaxLength(300);
|
||||
entity.Property(item => item.SessionIdleTimeoutHours).HasDefaultValue(3);
|
||||
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
||||
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
||||
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.TrackingRulesJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.ViewerStatsProviderBaseUrl).HasMaxLength(400);
|
||||
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
|
||||
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
|
||||
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
|
||||
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
|
||||
entity.Property(item => item.ShowactApplicationStartsAt);
|
||||
entity.Property(item => item.ShowactApplicationEndsAt);
|
||||
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
|
||||
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TeamMember>(entity =>
|
||||
@@ -81,24 +111,66 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.GroupName).HasMaxLength(80);
|
||||
entity.Property(item => item.Name).HasMaxLength(120);
|
||||
entity.Property(item => item.Description).HasMaxLength(400);
|
||||
entity.Property(item => item.ViewerRangeMin);
|
||||
entity.Property(item => item.ViewerRangeMax);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Candidate>(entity =>
|
||||
{
|
||||
entity.HasIndex(item => item.StreamerIdentityId);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||
entity.Property(item => item.NominationTally).HasDefaultValue(0);
|
||||
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
|
||||
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
|
||||
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
|
||||
entity.Property(item => item.ClipCompilationTitle).HasMaxLength(200);
|
||||
entity.Property(item => item.ClipCompilationPlatform).HasMaxLength(40);
|
||||
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<StreamerIdentity>(entity =>
|
||||
{
|
||||
entity.HasIndex(item => item.NormalizedKey).IsUnique();
|
||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||
entity.Property(item => item.Login).HasMaxLength(120);
|
||||
entity.Property(item => item.NormalizedKey).HasMaxLength(180);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.ProfileUrl).HasMaxLength(500);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Nomination>(entity =>
|
||||
{
|
||||
entity.Property(item => item.CategoryGroupName).HasMaxLength(80).HasDefaultValue(string.Empty);
|
||||
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.CandidateText).HasMaxLength(120);
|
||||
entity.Property(item => item.StreamUrl).HasMaxLength(300);
|
||||
entity.Property(item => item.ResolvedChannel).HasMaxLength(120);
|
||||
entity.Property(item => item.ResolvedPlatform).HasMaxLength(40);
|
||||
entity.Property(item => item.HoursStreamed);
|
||||
entity.Property(item => item.HoursWatched);
|
||||
entity.Property(item => item.PeakViewers);
|
||||
entity.Property(item => item.FollowersGained);
|
||||
entity.Property(item => item.TrackerStatus).HasMaxLength(40).HasDefaultValue("pending");
|
||||
entity.Property(item => item.TrackingReviewStatus).HasMaxLength(30).HasDefaultValue("clear");
|
||||
entity.Property(item => item.TrackingFlagsJson).HasDefaultValue("[]");
|
||||
entity.Property(item => item.TrackingReviewNote).HasMaxLength(1000);
|
||||
entity.Property(item => item.TrackingReviewedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.Status).HasMaxLength(20);
|
||||
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||
entity.HasIndex(item => new { item.SeasonId, item.CategoryGroupName, item.Status });
|
||||
entity.HasIndex(item => new { item.SeasonId, item.StreamerIdentityId, item.CategoryGroupName });
|
||||
entity.HasOne(item => item.Category)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.CategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(item => item.SuggestedCategory)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.SuggestedCategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<VoteBallot>(entity =>
|
||||
@@ -163,12 +235,42 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||
entity.HasIndex(item => item.CandidateId);
|
||||
entity.HasOne<Season>()
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.SeasonId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(item => item.Candidate)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.CandidateId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
SeedData.Apply(modelBuilder);
|
||||
modelBuilder.Entity<ShowactApplication>(entity =>
|
||||
{
|
||||
entity.Property(item => item.ArtistName).HasMaxLength(120);
|
||||
entity.Property(item => item.ContactEmail).HasMaxLength(180);
|
||||
entity.Property(item => item.ContactDiscord).HasMaxLength(120);
|
||||
entity.Property(item => item.PlatformUrl).HasMaxLength(500);
|
||||
entity.Property(item => item.PerformanceType).HasMaxLength(80);
|
||||
entity.Property(item => item.Description).HasMaxLength(1000);
|
||||
entity.Property(item => item.TechnicalNotes).HasMaxLength(1000);
|
||||
entity.Property(item => item.ReferenceUrl).HasMaxLength(500);
|
||||
entity.Property(item => item.Status).HasMaxLength(20);
|
||||
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Sponsor>(entity =>
|
||||
{
|
||||
entity.Property(item => item.Name).HasMaxLength(120);
|
||||
entity.Property(item => item.WebsiteUrl).HasMaxLength(500);
|
||||
entity.Property(item => item.LogoUrl).HasMaxLength(500);
|
||||
entity.Property(item => item.Description).HasMaxLength(500);
|
||||
entity.Property(item => item.Tier).HasMaxLength(80);
|
||||
entity.HasIndex(item => new { item.SeasonId, item.IsVisible, item.SortOrder });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static class OperationalTablesBootstrapper
|
||||
{
|
||||
public static Task EnsureAsync(AwardsDbContext db) =>
|
||||
db.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
ALTER TABLE "UserSessions"
|
||||
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "UserSessions"
|
||||
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchClientId" character varying(120) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchClientSecret" character varying(180) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchRedirectUri" character varying(400) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ContactContent" text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"SeasonId" integer NULL,
|
||||
"TwitchUserId" character varying(120) NULL,
|
||||
"Source" character varying(80) NOT NULL,
|
||||
"Type" character varying(80) NOT NULL,
|
||||
"Severity" character varying(20) NOT NULL,
|
||||
"Status" character varying(20) NOT NULL,
|
||||
"Summary" character varying(240) NOT NULL,
|
||||
"CreatedFromIp" character varying(80) NOT NULL,
|
||||
"UserAgent" character varying(400) NOT NULL,
|
||||
"MetadataJson" text NOT NULL,
|
||||
"ReviewNote" character varying(500) NULL,
|
||||
"ReviewedByTwitchId" character varying(120) NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL,
|
||||
"ReviewedAt" timestamp with time zone NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "RiskFlags"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt"
|
||||
ON "RiskFlags" ("Status", "CreatedAt" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_SeasonId"
|
||||
ON "RiskFlags" ("SeasonId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"AdminTwitchUserId" character varying(120) NOT NULL,
|
||||
"ActionType" character varying(80) NOT NULL,
|
||||
"EntityType" character varying(80) NOT NULL,
|
||||
"EntityId" character varying(120) NOT NULL,
|
||||
"Summary" character varying(240) NOT NULL,
|
||||
"MetadataJson" text NOT NULL,
|
||||
"CreatedFromIp" character varying(80) NOT NULL DEFAULT '',
|
||||
"UserAgent" character varying(400) NOT NULL DEFAULT '',
|
||||
"CreatedAt" timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "AdminAuditEntries"
|
||||
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "AdminAuditEntries"
|
||||
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
ALTER TABLE "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "CandidateId" integer NULL;
|
||||
|
||||
ALTER TABLE "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
|
||||
|
||||
ALTER TABLE "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
|
||||
ON "ClipSubmissions" ("SeasonId", "Status");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
|
||||
ON "ClipSubmissions" ("CandidateId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId'
|
||||
) THEN
|
||||
ALTER TABLE "ClipSubmissions"
|
||||
ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId"
|
||||
FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id")
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "Nominations"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
|
||||
ON "Nominations" ("SeasonId", "Status");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "TeamMembers" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"Login" character varying(80) NOT NULL,
|
||||
"DisplayName" character varying(120) NOT NULL,
|
||||
"Role" character varying(40) NOT NULL,
|
||||
"PasswordHash" character varying(120) NOT NULL,
|
||||
"PasswordSalt" character varying(80) NOT NULL,
|
||||
"BoundTwitchUserId" character varying(120) NULL,
|
||||
"BoundTwitchDisplayName" character varying(120) NULL,
|
||||
"MustChangePassword" boolean NOT NULL,
|
||||
"IsActive" boolean NOT NULL,
|
||||
"CreatedByTwitchId" character varying(120) NOT NULL,
|
||||
"UpdatedByTwitchId" character varying(120) NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL,
|
||||
"UpdatedAt" timestamp with time zone NULL,
|
||||
"LastLoginAt" timestamp with time zone NULL,
|
||||
"TwitchBoundAt" timestamp with time zone NULL,
|
||||
"PasswordResetAt" timestamp with time zone NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "TeamMembers"
|
||||
ADD COLUMN IF NOT EXISTS "BoundTwitchUserId" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "TeamMembers"
|
||||
ADD COLUMN IF NOT EXISTS "BoundTwitchDisplayName" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE "TeamMembers"
|
||||
ADD COLUMN IF NOT EXISTS "TwitchBoundAt" timestamp with time zone NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamMembers_Login"
|
||||
ON "TeamMembers" ("Login");
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamMembers_BoundTwitchUserId"
|
||||
ON "TeamMembers" ("BoundTwitchUserId")
|
||||
WHERE "BoundTwitchUserId" IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "TeamRolePermissions" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"Role" character varying(40) NOT NULL,
|
||||
"PermissionsJson" text NOT NULL,
|
||||
"UpdatedByTwitchId" character varying(120) NOT NULL,
|
||||
"UpdatedAt" timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamRolePermissions_Role"
|
||||
ON "TeamRolePermissions" ("Role");
|
||||
""");
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static partial class SeedDataBootstrapper
|
||||
{
|
||||
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
|
||||
{
|
||||
var seasonCategories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToArrayAsync();
|
||||
|
||||
foreach (var category in seasonCategories)
|
||||
{
|
||||
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug);
|
||||
category.GroupName = target.GroupName;
|
||||
category.Name = target.Name;
|
||||
category.Slug = target.Slug;
|
||||
category.Description = target.Description;
|
||||
category.SortOrder = target.SortOrder;
|
||||
category.MaxNomineesPerUser = 3;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var existing = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.Select(item => item.Slug)
|
||||
.ToArrayAsync();
|
||||
var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var seed in SeedCatalog.CategorySeeds)
|
||||
{
|
||||
if (existingSlugs.Contains(seed.Slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
db.Categories.Add(new Category
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
GroupName = seed.GroupName,
|
||||
Name = seed.Name,
|
||||
Slug = seed.Slug,
|
||||
Description = seed.Description,
|
||||
SortOrder = seed.SortOrder,
|
||||
MaxNomineesPerUser = 3,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
|
||||
{
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
|
||||
var existing = await db.Candidates
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.Select(item => new { item.CategoryId, item.DisplayName, item.ChannelSlug })
|
||||
.ToArrayAsync();
|
||||
var existingKeys = existing
|
||||
.Select(item => $"{item.CategoryId}|{item.DisplayName}|{item.ChannelSlug}".ToLowerInvariant())
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var seed in seeds)
|
||||
{
|
||||
if (!categories.TryGetValue(seed.CategorySlug, out var category))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"{category.Id}|{seed.DisplayName}|{seed.ChannelSlug}".ToLowerInvariant();
|
||||
if (existingKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
db.Candidates.Add(new Candidate
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = category.Id,
|
||||
DisplayName = seed.DisplayName,
|
||||
ChannelSlug = seed.ChannelSlug,
|
||||
Platform = seed.Platform,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task EnsureWinnersAsync(AwardsDbContext db, Season season, WinnerSeed[] seeds)
|
||||
{
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
|
||||
var candidates = await db.Candidates
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToArrayAsync();
|
||||
var existingResults = await db.Results
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToArrayAsync();
|
||||
foreach (var result in existingResults)
|
||||
{
|
||||
if (categories.Values.FirstOrDefault(item => item.Id == result.CategoryId) is { } category
|
||||
&& result.CategoryName != category.Name)
|
||||
{
|
||||
result.CategoryName = category.Name;
|
||||
}
|
||||
}
|
||||
|
||||
var existingResultCategoryIds = existingResults.Select(item => item.CategoryId).ToHashSet();
|
||||
|
||||
foreach (var seed in seeds)
|
||||
{
|
||||
if (!categories.TryGetValue(seed.CategorySlug, out var category) || existingResultCategoryIds.Contains(category.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = candidates.FirstOrDefault(item =>
|
||||
item.CategoryId == category.Id
|
||||
&& string.Equals(item.DisplayName, seed.DisplayName, StringComparison.OrdinalIgnoreCase));
|
||||
if (candidate is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
db.Results.Add(new AwardResult
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = category.Id,
|
||||
CandidateId = candidate.Id,
|
||||
CategoryName = category.Name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
namespace Backend.Data;
|
||||
|
||||
internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder);
|
||||
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||
internal sealed record SiteFaqSeed(string Question, string Answer);
|
||||
internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon);
|
||||
|
||||
internal static class SeedCatalog
|
||||
{
|
||||
internal static readonly CategorySeed[] CategorySeeds =
|
||||
[
|
||||
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1),
|
||||
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2),
|
||||
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
|
||||
new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
|
||||
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
|
||||
new("Entertainment", "Best Variety", "best-variety", "Talk, Comedy, Watchalongs und kreative Streamformate.", 6),
|
||||
new("Community", "Community Liebling", "community-liebling", "Creator:innen, die ihre Community besonders stark verbinden.", 7),
|
||||
new("Collab", "Best Collab & Duo", "best-collab-duo", "Gemeinsame Streams, Projekte und Duo-Dynamik.", 8),
|
||||
];
|
||||
|
||||
internal static readonly Dictionary<string, string> LegacyCategorySlugMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["bestes-live-event"] = "best-newcomer",
|
||||
["clip-des-jahres"] = "model-design",
|
||||
["beste-community"] = "gesang-musik",
|
||||
};
|
||||
|
||||
internal static readonly SiteFaqSeed[] SiteFaqSeeds =
|
||||
[
|
||||
new(
|
||||
"Wer darf nominiert werden?",
|
||||
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
|
||||
new(
|
||||
"Wie funktioniert das Voting?",
|
||||
"Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."),
|
||||
new(
|
||||
"Was kostet die Teilnahme?",
|
||||
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."),
|
||||
new(
|
||||
"Wann und wo findet die Award-Show statt?",
|
||||
"Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"),
|
||||
new(
|
||||
"Ich wurde nominiert — was nun?",
|
||||
"Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."),
|
||||
];
|
||||
|
||||
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
|
||||
[
|
||||
new("Twitch", "twitch", "https://twitch.tv/jayuhime", "twitch"),
|
||||
new("YouTube", "youtube", "https://youtube.com/c/Jayuhime", "youtube"),
|
||||
new("X", "x", "https://x.com/jayuhime", "x"),
|
||||
new("Instagram", "instagram", "https://instagram.com/jayuhime", "instagram"),
|
||||
new("Discord", "discord", "https://discord.gg/jayuhime", "discord"),
|
||||
];
|
||||
|
||||
internal const string DefaultImprintContent = """
|
||||
Anbieter
|
||||
VTuber Star Awards, vertreten durch Jayuhime.
|
||||
|
||||
Kontakt
|
||||
Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||
|
||||
Hinweis
|
||||
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.
|
||||
""";
|
||||
|
||||
internal const string DefaultContactContent = """
|
||||
Kontakt zum Award-Team
|
||||
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.
|
||||
|
||||
Datenschutzfragen
|
||||
Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||
|
||||
Community & Kooperationen
|
||||
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||
""";
|
||||
|
||||
internal const string DefaultSponsorsContent = """
|
||||
Sponsoren & Partner
|
||||
Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
||||
|
||||
Partner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.
|
||||
""";
|
||||
|
||||
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
||||
[
|
||||
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
||||
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new("best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
||||
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new("community-liebling", "Lumi", "@lumi_vt", "Cake"),
|
||||
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||
];
|
||||
|
||||
internal static readonly WinnerSeed[] WinnerSeeds =
|
||||
[
|
||||
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
||||
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
||||
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"),
|
||||
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"),
|
||||
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
||||
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"),
|
||||
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"),
|
||||
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"),
|
||||
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
||||
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"),
|
||||
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"),
|
||||
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"),
|
||||
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"),
|
||||
];
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static class SeedData
|
||||
{
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<SiteSettings>().HasData(
|
||||
new SiteSettings
|
||||
{
|
||||
Id = 1,
|
||||
HostDisplayName = "Jayuhime",
|
||||
HostTagline = "VTuber & Award Host",
|
||||
NewsletterUrl = "https://vtuber-star-awards.de/newsletter",
|
||||
PrivacyEmail = "datenschutz@vtuber-star-awards.de",
|
||||
PrivacyPolicyContent = """
|
||||
Verantwortliche:r
|
||||
VTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de
|
||||
|
||||
Welche Daten wir verarbeiten
|
||||
Bei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.
|
||||
Für Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.
|
||||
|
||||
Rechtsgrundlage
|
||||
Verarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.
|
||||
|
||||
Zweck der Verarbeitung
|
||||
Durchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.
|
||||
|
||||
Löschfristen
|
||||
Alle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.
|
||||
|
||||
Deine Rechte
|
||||
Du hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.
|
||||
|
||||
Weitergabe an Dritte
|
||||
Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.
|
||||
""",
|
||||
PrivacyPolicyUpdatedBy = "seed",
|
||||
PrivacyPolicyUpdatedAt = new DateTimeOffset(2026, 6, 23, 0, 0, 0, TimeSpan.Zero),
|
||||
ImprintUrl = "https://vtuber-star-awards.de/impressum",
|
||||
ImprintContent = SeedCatalog.DefaultImprintContent,
|
||||
ContactUrl = "https://vtuber-star-awards.de/kontakt",
|
||||
ContactContent = SeedCatalog.DefaultContactContent,
|
||||
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
|
||||
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
|
||||
SocialLinksJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true },
|
||||
new { label = "YouTube", platform = "youtube", url = "https://youtube.com/c/Jayuhime", icon = "youtube", showOnHost = true, showOnCommunity = true },
|
||||
new { label = "X", platform = "x", url = "https://x.com/jayuhime", icon = "x", showOnHost = true, showOnCommunity = true },
|
||||
new { label = "Instagram", platform = "instagram", url = "https://instagram.com/jayuhime", icon = "instagram", showOnHost = true, showOnCommunity = true },
|
||||
new { label = "Discord", platform = "discord", url = "https://discord.gg/jayuhime", icon = "discord", showOnHost = true, showOnCommunity = true },
|
||||
}),
|
||||
FaqJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { question = "Wer darf nominiert werden?", answer = "Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhängig von Follower-Zahl oder Plattform. Die Community schlägt in der Nominierungsphase ihre Favorit:innen vor." },
|
||||
new { question = "Wie funktioniert das Voting?", answer = "Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase." },
|
||||
new { question = "Was kostet die Teilnahme?", answer = "Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans." },
|
||||
new { question = "Wann und wo findet die Award-Show statt?", answer = "Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!" },
|
||||
new { question = "Ich wurde nominiert — was nun?", answer = "Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt." },
|
||||
}),
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Season>().HasData(
|
||||
new Season
|
||||
{
|
||||
Id = 1,
|
||||
Year = 2026,
|
||||
Name = "VTuber Star Awards 2026",
|
||||
ShowStreamUrl = "https://twitch.tv/jayuhime",
|
||||
IsCurrent = true,
|
||||
IsCommunityOnly = true,
|
||||
CurrentPhase = "Community Voting",
|
||||
NominationStartsAt = new DateOnly(2026, 5, 1),
|
||||
NominationEndsAt = new DateOnly(2026, 5, 31),
|
||||
VotingStartsAt = new DateOnly(2026, 6, 1),
|
||||
VotingEndsAt = new DateOnly(2026, 6, 30),
|
||||
ReviewStartsAt = new DateOnly(2026, 7, 1),
|
||||
ReviewEndsAt = new DateOnly(2026, 7, 10),
|
||||
ShowDate = new DateOnly(2026, 7, 20),
|
||||
ShowStartsAt = new TimeOnly(20, 0),
|
||||
},
|
||||
new Season
|
||||
{
|
||||
Id = 2,
|
||||
Year = 2025,
|
||||
Name = "VTuber Star Awards 2025",
|
||||
ShowStreamUrl = "https://twitch.tv/jayuhime",
|
||||
IsCurrent = false,
|
||||
IsCommunityOnly = true,
|
||||
CurrentPhase = "Archived",
|
||||
NominationStartsAt = new DateOnly(2025, 5, 1),
|
||||
NominationEndsAt = new DateOnly(2025, 5, 31),
|
||||
VotingStartsAt = new DateOnly(2025, 6, 1),
|
||||
VotingEndsAt = new DateOnly(2025, 6, 30),
|
||||
ReviewStartsAt = new DateOnly(2025, 7, 1),
|
||||
ReviewEndsAt = new DateOnly(2025, 7, 10),
|
||||
ShowDate = new DateOnly(2025, 7, 20),
|
||||
ShowStartsAt = new TimeOnly(20, 0),
|
||||
},
|
||||
new Season
|
||||
{
|
||||
Id = 3,
|
||||
Year = 2024,
|
||||
Name = "VTuber Star Awards 2024",
|
||||
ShowStreamUrl = "https://youtube.com/c/Jayuhime",
|
||||
IsCurrent = false,
|
||||
IsCommunityOnly = true,
|
||||
CurrentPhase = "Archived",
|
||||
NominationStartsAt = new DateOnly(2024, 5, 1),
|
||||
NominationEndsAt = new DateOnly(2024, 5, 31),
|
||||
VotingStartsAt = new DateOnly(2024, 6, 1),
|
||||
VotingEndsAt = new DateOnly(2024, 6, 30),
|
||||
ReviewStartsAt = new DateOnly(2024, 7, 1),
|
||||
ReviewEndsAt = new DateOnly(2024, 7, 10),
|
||||
ShowDate = new DateOnly(2024, 7, 20),
|
||||
ShowStartsAt = new TimeOnly(20, 0),
|
||||
},
|
||||
new Season
|
||||
{
|
||||
Id = 4,
|
||||
Year = 2023,
|
||||
Name = "VTuber Star Awards 2023",
|
||||
ShowStreamUrl = "https://twitch.tv/jayuhime",
|
||||
IsCurrent = false,
|
||||
IsCommunityOnly = true,
|
||||
CurrentPhase = "Archived",
|
||||
NominationStartsAt = new DateOnly(2023, 5, 1),
|
||||
NominationEndsAt = new DateOnly(2023, 5, 31),
|
||||
VotingStartsAt = new DateOnly(2023, 6, 1),
|
||||
VotingEndsAt = new DateOnly(2023, 6, 30),
|
||||
ReviewStartsAt = new DateOnly(2023, 7, 1),
|
||||
ReviewEndsAt = new DateOnly(2023, 7, 10),
|
||||
ShowDate = new DateOnly(2023, 7, 20),
|
||||
ShowStartsAt = new TimeOnly(20, 0),
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Category>().HasData(
|
||||
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die groesste Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 5, SeasonId = 2, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2025.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 6, SeasonId = 2, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Archivkategorie 2025.", SortOrder = 2, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 7, SeasonId = 2, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Archivkategorie 2025.", SortOrder = 3, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 8, SeasonId = 3, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2024.", SortOrder = 1, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 9, SeasonId = 3, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Archivkategorie 2024.", SortOrder = 2, MaxNomineesPerUser = 3 },
|
||||
new Category { Id = 10, SeasonId = 4, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2023.", SortOrder = 1, MaxNomineesPerUser = 3 });
|
||||
|
||||
modelBuilder.Entity<Candidate>().HasData(
|
||||
new Candidate { Id = 1, SeasonId = 1, CategoryId = 1, DisplayName = "Hoshimi Miyu", ChannelSlug = "@hoshimimiyu", Platform = "Twitch" },
|
||||
new Candidate { Id = 2, SeasonId = 1, CategoryId = 1, DisplayName = "Kurainu", ChannelSlug = "@kurainu", Platform = "Twitch" },
|
||||
new Candidate { Id = 3, SeasonId = 1, CategoryId = 1, DisplayName = "Shiro Ch.", ChannelSlug = "@shiroch", Platform = "Twitch" },
|
||||
new Candidate { Id = 4, SeasonId = 1, CategoryId = 2, DisplayName = "Kurainu 3D Live", ChannelSlug = "@kurainu", Platform = "Twitch" },
|
||||
new Candidate { Id = 5, SeasonId = 1, CategoryId = 2, DisplayName = "Aoi Sakura Showcase", ChannelSlug = "@aoisakura", Platform = "YouTube" },
|
||||
new Candidate { Id = 6, SeasonId = 1, CategoryId = 3, DisplayName = "Pyonkichi Kingdom", ChannelSlug = "@pyonkichikingdom", Platform = "Twitch" },
|
||||
new Candidate { Id = 7, SeasonId = 1, CategoryId = 4, DisplayName = "Moonrelay", ChannelSlug = "@moonrelay", Platform = "Twitch" },
|
||||
new Candidate { Id = 8, SeasonId = 2, CategoryId = 5, DisplayName = "Hoshimi Miyu", ChannelSlug = "@hoshimimiyu", Platform = "Twitch" },
|
||||
new Candidate { Id = 9, SeasonId = 2, CategoryId = 6, DisplayName = "Kurainu 3D Live", ChannelSlug = "@kurainu", Platform = "Twitch" },
|
||||
new Candidate { Id = 10, SeasonId = 2, CategoryId = 7, DisplayName = "Pyonkichi Kingdom", ChannelSlug = "@pyonkichikingdom", Platform = "Twitch" },
|
||||
new Candidate { Id = 11, SeasonId = 3, CategoryId = 8, DisplayName = "Aoi Sakura", ChannelSlug = "@aoisakura", Platform = "YouTube" },
|
||||
new Candidate { Id = 12, SeasonId = 3, CategoryId = 9, DisplayName = "Starbyte", ChannelSlug = "@starbyte", Platform = "Twitch" },
|
||||
new Candidate { Id = 13, SeasonId = 4, CategoryId = 10, DisplayName = "Tenshi Vox", ChannelSlug = "@tenshivox", Platform = "Twitch" });
|
||||
|
||||
modelBuilder.Entity<AwardResult>().HasData(
|
||||
new AwardResult { Id = 1, SeasonId = 2, CategoryId = 5, CandidateId = 8, CategoryName = "VTuber des Jahres" },
|
||||
new AwardResult { Id = 2, SeasonId = 2, CategoryId = 6, CandidateId = 9, CategoryName = "Bestes Live Event" },
|
||||
new AwardResult { Id = 3, SeasonId = 2, CategoryId = 7, CandidateId = 10, CategoryName = "Clip des Jahres" },
|
||||
new AwardResult { Id = 4, SeasonId = 3, CategoryId = 8, CandidateId = 11, CategoryName = "VTuber des Jahres" },
|
||||
new AwardResult { Id = 5, SeasonId = 3, CategoryId = 9, CandidateId = 12, CategoryName = "Clip des Jahres" },
|
||||
new AwardResult { Id = 6, SeasonId = 4, CategoryId = 10, CandidateId = 13, CategoryName = "VTuber des Jahres" });
|
||||
|
||||
modelBuilder.Entity<Nomination>().HasData(
|
||||
new Nomination { Id = 1, SeasonId = 1, CategoryId = 1, SubmittedByTwitchId = "twitch_hoshi", CandidateText = "Hoshimi Miyu", CreatedAt = new DateTimeOffset(2026, 6, 10, 13, 0, 0, TimeSpan.Zero) },
|
||||
new Nomination { Id = 2, SeasonId = 1, CategoryId = 2, SubmittedByTwitchId = "twitch_kurainu", CandidateText = "Kurainu 3D Live", CreatedAt = new DateTimeOffset(2026, 6, 10, 14, 0, 0, TimeSpan.Zero) });
|
||||
|
||||
modelBuilder.Entity<VoteBallot>().HasData(
|
||||
new VoteBallot { Id = 1, SeasonId = 1, SubmittedByTwitchId = "twitch_vote_1", Status = "submitted", SubmittedAt = new DateTimeOffset(2026, 6, 11, 12, 0, 0, TimeSpan.Zero) },
|
||||
new VoteBallot { Id = 2, SeasonId = 1, SubmittedByTwitchId = "twitch_vote_2", Status = "submitted", SubmittedAt = new DateTimeOffset(2026, 6, 11, 12, 5, 0, TimeSpan.Zero) });
|
||||
|
||||
modelBuilder.Entity<VoteEntry>().HasData(
|
||||
new VoteEntry { Id = 1, BallotId = 1, CategoryId = 1, CandidateId = 1 },
|
||||
new VoteEntry { Id = 2, BallotId = 1, CategoryId = 2, CandidateId = 4 },
|
||||
new VoteEntry { Id = 3, BallotId = 2, CategoryId = 1, CandidateId = 2 },
|
||||
new VoteEntry { Id = 4, BallotId = 2, CategoryId = 3, CandidateId = 6 });
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static partial class SeedDataBootstrapper
|
||||
{
|
||||
public static async Task EnsureAsync(AwardsDbContext db)
|
||||
{
|
||||
await EnsureSiteSettingsAsync(db);
|
||||
|
||||
var seasons = await db.Seasons.ToDictionaryAsync(item => item.Year);
|
||||
if (seasons.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var season in seasons.Values)
|
||||
{
|
||||
await EnsureCategoriesAsync(db, season);
|
||||
}
|
||||
|
||||
if (seasons.TryGetValue(2026, out var currentSeason))
|
||||
{
|
||||
await EnsureCandidatesAsync(db, currentSeason, SeedCatalog.CurrentCandidateSeeds);
|
||||
await EnsureSeedOperationalDataAsync(db, currentSeason);
|
||||
}
|
||||
|
||||
foreach (var year in new[] { 2025, 2024, 2023 })
|
||||
{
|
||||
if (!seasons.TryGetValue(year, out var season))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var winners = SeedCatalog.WinnerSeeds.Where(item => item.Year == year).ToArray();
|
||||
await EnsureCandidatesAsync(db, season, winners.Select(item => new CandidateSeed(item.CategorySlug, item.DisplayName, item.ChannelSlug, item.Platform)).ToArray());
|
||||
await EnsureWinnersAsync(db, season, winners);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static partial class SeedDataBootstrapper
|
||||
{
|
||||
private static async Task EnsureSeedOperationalDataAsync(AwardsDbContext db, Season season)
|
||||
{
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
|
||||
var candidates = await db.Candidates
|
||||
.Where(item => item.SeasonId == season.Id)
|
||||
.ToArrayAsync();
|
||||
|
||||
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
|
||||
|
||||
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
|
||||
{
|
||||
db.ClipSubmissions.AddRange(
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"),
|
||||
SubmittedByTwitchId = "local_user_3",
|
||||
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
|
||||
Title = "Starlight Debut Moment",
|
||||
Creator = "Hoshimi Miyu",
|
||||
Platform = "Twitch",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
|
||||
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 5, 0, TimeSpan.Zero),
|
||||
},
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"),
|
||||
SubmittedByTwitchId = "local_user_4",
|
||||
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
|
||||
Title = "Finale-Hype mit Chat-Chaos",
|
||||
Creator = "Kurainu",
|
||||
Platform = "Twitch",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
|
||||
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 10, 0, TimeSpan.Zero),
|
||||
},
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "best-gaming"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"),
|
||||
SubmittedByTwitchId = "local_user",
|
||||
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
|
||||
Title = "Epischer Clutch im Finale",
|
||||
Creator = "Kurainu",
|
||||
Platform = "Twitch",
|
||||
Status = "pending",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 17, 9, 10, 0, TimeSpan.Zero),
|
||||
},
|
||||
new ClipSubmission
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
CategoryId = ResolveCategoryId(categories, "gesang-musik"),
|
||||
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"),
|
||||
SubmittedByTwitchId = "local_user_2",
|
||||
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
|
||||
Title = "Live-Cover mit Gänsehaut",
|
||||
Creator = "Melo Diva",
|
||||
Platform = "YouTube",
|
||||
Status = "approved",
|
||||
ReviewNote = "Geprüfter Clip fuer Review-Workflow.",
|
||||
ReviewedByTwitchId = "jayuhime_admin",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
|
||||
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 0, 0, TimeSpan.Zero),
|
||||
});
|
||||
}
|
||||
|
||||
if (!normalizedLegacyState.HasRiskSeed && !await db.RiskFlags.AnyAsync(item => item.Source == "seed"))
|
||||
{
|
||||
db.RiskFlags.Add(new RiskFlag
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
TwitchUserId = "sample_user",
|
||||
Source = "seed",
|
||||
Type = "rapid_vote_updates",
|
||||
Severity = "medium",
|
||||
Status = "open",
|
||||
Summary = "Mehrere Voting-Aenderungen in kurzer Zeit erkannt.",
|
||||
CreatedFromIp = "127.0.0.1",
|
||||
UserAgent = "seed-bootstrap",
|
||||
MetadataJson = JsonSerializer.Serialize(new { recentVoteSubmissions = 3 }),
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 40, 0, TimeSpan.Zero),
|
||||
});
|
||||
}
|
||||
|
||||
if (!normalizedLegacyState.HasAuditSeed && !await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"))
|
||||
{
|
||||
db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||
{
|
||||
AdminTwitchUserId = "system",
|
||||
ActionType = "seed.initialize",
|
||||
EntityType = "database",
|
||||
EntityId = season.Year.ToString(),
|
||||
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
|
||||
MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }),
|
||||
CreatedFromIp = "seed",
|
||||
UserAgent = "seed-bootstrap",
|
||||
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ResolveCategoryId(IReadOnlyDictionary<string, Category> categories, string slug) =>
|
||||
categories.TryGetValue(slug, out var category) ? category.Id : null;
|
||||
|
||||
private static int? ResolveCandidateId(
|
||||
IReadOnlyDictionary<string, Category> categories,
|
||||
IEnumerable<Candidate> candidates,
|
||||
string categorySlug,
|
||||
string displayName)
|
||||
{
|
||||
var categoryId = ResolveCategoryId(categories, categorySlug);
|
||||
return categoryId is int resolvedCategoryId
|
||||
? candidates.FirstOrDefault(item =>
|
||||
item.CategoryId == resolvedCategoryId
|
||||
&& string.Equals(item.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))?.Id
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task<LegacySeedState> NormalizeLegacyDemoLabelsAsync(AwardsDbContext db)
|
||||
{
|
||||
var legacySessions = await db.UserSessions
|
||||
.Where(item => item.TwitchUserId == "admin_demo" || item.TwitchUserId == "jayuhime_demo" || item.TwitchUserId == "demo_user")
|
||||
.ToArrayAsync();
|
||||
|
||||
foreach (var session in legacySessions)
|
||||
{
|
||||
session.TwitchUserId = session.TwitchUserId switch
|
||||
{
|
||||
"admin_demo" => "jayuhime_admin",
|
||||
"jayuhime_demo" => "jayuhime_viewer",
|
||||
"demo_user" => "local_user",
|
||||
_ => session.TwitchUserId,
|
||||
};
|
||||
session.DisplayName = session.DisplayName switch
|
||||
{
|
||||
"Admin Demo" => "Jayuhime Admin",
|
||||
"Demo User" => "Local User",
|
||||
_ => session.DisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
var legacyClipSubmissions = await db.ClipSubmissions
|
||||
.Where(item =>
|
||||
item.SubmittedByTwitchId == "demo_user" ||
|
||||
item.SubmittedByTwitchId == "demo_user_2" ||
|
||||
item.ClipUrl.Contains("Demo") ||
|
||||
item.ClipUrl.Contains("demo") ||
|
||||
(item.ReviewNote != null && item.ReviewNote.Contains("Demo-Clip")))
|
||||
.ToArrayAsync();
|
||||
|
||||
foreach (var clip in legacyClipSubmissions)
|
||||
{
|
||||
clip.SubmittedByTwitchId = clip.SubmittedByTwitchId switch
|
||||
{
|
||||
"demo_user" => "local_user",
|
||||
"demo_user_2" => "local_user_2",
|
||||
_ => clip.SubmittedByTwitchId,
|
||||
};
|
||||
clip.ClipUrl = clip.ClipUrl
|
||||
.Replace("DemoGamingMoment", "EpicGamingMoment")
|
||||
.Replace("demoSong", "liveCoverMoment");
|
||||
clip.ReviewNote = clip.ReviewNote?.Replace("Demo-Clip", "Geprüfter Clip");
|
||||
}
|
||||
await LinkExistingClipsToCandidatesAsync(db);
|
||||
|
||||
var legacyRiskFlags = await db.RiskFlags
|
||||
.Where(item =>
|
||||
item.Source == "demo" ||
|
||||
item.Summary.StartsWith("Demo:") ||
|
||||
item.TwitchUserId == "jayuhime_demo" ||
|
||||
item.TwitchUserId == "demo_user")
|
||||
.ToArrayAsync();
|
||||
|
||||
foreach (var flag in legacyRiskFlags)
|
||||
{
|
||||
flag.Source = "seed";
|
||||
flag.TwitchUserId = flag.TwitchUserId switch
|
||||
{
|
||||
"demo_user" => "local_user",
|
||||
"jayuhime_demo" => "jayuhime_viewer",
|
||||
_ => flag.TwitchUserId,
|
||||
};
|
||||
flag.Summary = flag.Summary.Replace("Demo: ", string.Empty);
|
||||
flag.UserAgent = flag.UserAgent == "demo-seed" ? "seed-bootstrap" : flag.UserAgent;
|
||||
}
|
||||
|
||||
var legacyAuditEntries = await db.AdminAuditEntries
|
||||
.Where(item =>
|
||||
item.ActionType == "demo.seed" ||
|
||||
item.Summary.Contains("Demo-Inhalte") ||
|
||||
item.AdminTwitchUserId == "admin_demo" ||
|
||||
item.AdminTwitchUserId == "jayuhime_demo")
|
||||
.ToArrayAsync();
|
||||
|
||||
foreach (var entry in legacyAuditEntries)
|
||||
{
|
||||
entry.AdminTwitchUserId = entry.AdminTwitchUserId switch
|
||||
{
|
||||
"admin_demo" => "jayuhime_admin",
|
||||
"jayuhime_demo" => "jayuhime_viewer",
|
||||
_ => entry.AdminTwitchUserId,
|
||||
};
|
||||
if (entry.ActionType == "demo.seed")
|
||||
{
|
||||
entry.ActionType = "seed.initialize";
|
||||
}
|
||||
if (entry.Summary.Contains("Demo-Inhalte"))
|
||||
{
|
||||
entry.Summary = "Startinhalte wurden in der Datenbank bereitgestellt.";
|
||||
}
|
||||
}
|
||||
|
||||
return new LegacySeedState(
|
||||
legacyRiskFlags.Length > 0 || await db.RiskFlags.AnyAsync(item => item.Source == "seed"),
|
||||
legacyAuditEntries.Length > 0 || await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"));
|
||||
}
|
||||
|
||||
private static async Task LinkExistingClipsToCandidatesAsync(AwardsDbContext db)
|
||||
{
|
||||
var clips = await db.ClipSubmissions
|
||||
.Where(item => item.CandidateId == null && item.CategoryId != null && item.Creator != string.Empty)
|
||||
.ToArrayAsync();
|
||||
if (clips.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seasonIds = clips.Select(item => item.SeasonId).Distinct().ToArray();
|
||||
var categoryIds = clips.Select(item => item.CategoryId!.Value).Distinct().ToArray();
|
||||
var candidates = await db.Candidates
|
||||
.Where(item => seasonIds.Contains(item.SeasonId) && categoryIds.Contains(item.CategoryId))
|
||||
.ToArrayAsync();
|
||||
|
||||
foreach (var clip in clips)
|
||||
{
|
||||
var creatorKey = NormalizeSeedCandidateKey(clip.Creator);
|
||||
var candidate = candidates.FirstOrDefault(item =>
|
||||
item.SeasonId == clip.SeasonId
|
||||
&& item.CategoryId == clip.CategoryId
|
||||
&& (NormalizeSeedCandidateKey(item.DisplayName) == creatorKey
|
||||
|| NormalizeSeedCandidateKey(item.ChannelSlug) == creatorKey));
|
||||
|
||||
if (candidate is not null)
|
||||
{
|
||||
clip.CandidateId = candidate.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeSeedCandidateKey(string value) =>
|
||||
new(
|
||||
value
|
||||
.Trim()
|
||||
.TrimStart('@')
|
||||
.ToLowerInvariant()
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.ToArray());
|
||||
|
||||
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static partial class SeedDataBootstrapper
|
||||
{
|
||||
private static async Task EnsureSiteSettingsAsync(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HasValidSiteArray(settings.FaqJson, "question", "answer"))
|
||||
{
|
||||
settings.FaqJson = JsonSerializer.Serialize(SeedCatalog.SiteFaqSeeds.Select(item => new
|
||||
{
|
||||
question = item.Question,
|
||||
answer = item.Answer,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!HasValidSiteArray(settings.SocialLinksJson, "label", "platform", "url"))
|
||||
{
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(SeedCatalog.SiteSocialSeeds.Select(item => new
|
||||
{
|
||||
label = item.Label,
|
||||
platform = item.Platform,
|
||||
url = item.Url,
|
||||
icon = item.Icon,
|
||||
showOnHost = true,
|
||||
showOnCommunity = true,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!HasValidRiskRules(settings.RiskRulesJson))
|
||||
{
|
||||
settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.ImprintContent))
|
||||
{
|
||||
settings.ImprintContent = SeedCatalog.DefaultImprintContent;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.ContactContent))
|
||||
{
|
||||
settings.ContactContent = SeedCatalog.DefaultContactContent;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.SponsorsContent))
|
||||
{
|
||||
settings.SponsorsContent = SeedCatalog.DefaultSponsorsContent;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return document.RootElement.EnumerateArray().Any(item =>
|
||||
item.ValueKind == JsonValueKind.Object
|
||||
&& requiredKeys.All(key =>
|
||||
item.TryGetProperty(key, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(value.GetString())));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasValidRiskRules(string? json) =>
|
||||
RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static class SessionBootstrapper
|
||||
{
|
||||
public static Task EnsureAsync(AwardsDbContext db) =>
|
||||
db.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "UserSessions" (
|
||||
"Id" uuid NOT NULL PRIMARY KEY,
|
||||
"SessionToken" character varying(120) NOT NULL,
|
||||
"TwitchUserId" character varying(120) NOT NULL,
|
||||
"DisplayName" character varying(120) NOT NULL,
|
||||
"Role" character varying(40) NOT NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL,
|
||||
"LastSeenAt" timestamp with time zone NOT NULL,
|
||||
"IsActive" boolean NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_UserSessions_SessionToken"
|
||||
ON "UserSessions" ("SessionToken");
|
||||
""");
|
||||
}
|
||||
@@ -7,7 +7,16 @@ public sealed class Candidate
|
||||
public Season Season { get; set; } = null!;
|
||||
public int CategoryId { get; set; }
|
||||
public Category Category { get; set; } = null!;
|
||||
public int? StreamerIdentityId { get; set; }
|
||||
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string ChannelSlug { get; set; } = string.Empty;
|
||||
public string Platform { get; set; } = "Twitch";
|
||||
public int NominationTally { get; set; }
|
||||
public string AcceptanceStatus { get; set; } = "open";
|
||||
public string? AcceptanceNote { get; set; }
|
||||
public string? ClipCompilationUrl { get; set; }
|
||||
public string? ClipCompilationTitle { get; set; }
|
||||
public string? ClipCompilationPlatform { get; set; }
|
||||
public string ClipEmbedStatus { get; set; } = "unchecked";
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@ public sealed class Category
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public int MaxNomineesPerUser { get; set; }
|
||||
public int? ViewerRangeMin { get; set; }
|
||||
public int? ViewerRangeMax { get; set; }
|
||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -5,13 +5,32 @@ public sealed class Nomination
|
||||
public int Id { get; set; }
|
||||
public int SeasonId { get; set; }
|
||||
public Season Season { get; set; } = null!;
|
||||
public int CategoryId { get; set; }
|
||||
public Category Category { get; set; } = null!;
|
||||
public int? CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
public string CategoryGroupName { get; set; } = string.Empty;
|
||||
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
||||
public int? CandidateId { get; set; }
|
||||
public Candidate? Candidate { get; set; }
|
||||
public int? StreamerIdentityId { get; set; }
|
||||
public StreamerIdentity? StreamerIdentity { get; set; }
|
||||
public int? SuggestedCategoryId { get; set; }
|
||||
public Category? SuggestedCategory { get; set; }
|
||||
public string? CandidateText { get; set; }
|
||||
public string? StreamUrl { get; set; }
|
||||
public string? ResolvedChannel { get; set; }
|
||||
public string? ResolvedPlatform { get; set; }
|
||||
public int? AvgViewers { get; set; }
|
||||
public int? HoursStreamed { get; set; }
|
||||
public int? HoursWatched { get; set; }
|
||||
public int? PeakViewers { get; set; }
|
||||
public int? FollowersGained { get; set; }
|
||||
public string TrackerStatus { get; set; } = "pending";
|
||||
public DateTimeOffset? TrackerCheckedAt { get; set; }
|
||||
public string TrackingReviewStatus { get; set; } = "clear";
|
||||
public string TrackingFlagsJson { get; set; } = "[]";
|
||||
public string? TrackingReviewNote { get; set; }
|
||||
public string? TrackingReviewedByTwitchId { get; set; }
|
||||
public DateTimeOffset? TrackingReviewedAt { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? ReviewNote { get; set; }
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
|
||||
@@ -5,7 +5,7 @@ public sealed class Season
|
||||
public int Id { get; set; }
|
||||
public int Year { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string ShowStreamUrl { get; set; } = string.Empty;
|
||||
public bool IsDemo { get; set; }
|
||||
public bool IsCurrent { get; set; }
|
||||
public bool IsCommunityOnly { get; set; }
|
||||
public string CurrentPhase { get; set; } = string.Empty;
|
||||
@@ -17,6 +17,10 @@ public sealed class Season
|
||||
public DateOnly ReviewEndsAt { get; set; }
|
||||
public DateOnly ShowDate { get; set; }
|
||||
public TimeOnly ShowStartsAt { get; set; } = new(20, 0);
|
||||
public DateTimeOffset? WinnersPublishedAt { get; set; }
|
||||
public string? WinnersPublishedByTwitchId { get; set; }
|
||||
public string SubcategoryTemplatesJson { get; set; } = "[]";
|
||||
public string WorkflowRulesJson { get; set; } = "[]";
|
||||
public ICollection<Category> Categories { get; set; } = [];
|
||||
public ICollection<AwardResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class ShowactApplication
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SeasonId { get; set; }
|
||||
public Season Season { get; set; } = null!;
|
||||
public string ArtistName { get; set; } = string.Empty;
|
||||
public string ContactEmail { get; set; } = string.Empty;
|
||||
public string ContactDiscord { get; set; } = string.Empty;
|
||||
public string PlatformUrl { get; set; } = string.Empty;
|
||||
public string PerformanceType { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string TechnicalNotes { get; set; } = string.Empty;
|
||||
public string ReferenceUrl { get; set; } = string.Empty;
|
||||
public string FieldResponsesJson { get; set; } = "{}";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? ReviewNote { get; set; }
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
public string CreatedFromIp { get; set; } = string.Empty;
|
||||
public string UserAgent { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
}
|
||||
@@ -6,6 +6,8 @@ public sealed class SiteSettings
|
||||
public string HostDisplayName { get; set; } = string.Empty;
|
||||
public string HostTagline { get; set; } = string.Empty;
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ShareXUrl { get; set; } = string.Empty;
|
||||
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||
public string PrivacyEmail { get; set; } = string.Empty;
|
||||
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
||||
public string? PrivacyPolicyUpdatedBy { get; set; }
|
||||
@@ -16,9 +18,42 @@ public sealed class SiteSettings
|
||||
public string ContactContent { get; set; } = string.Empty;
|
||||
public string SponsorsUrl { get; set; } = string.Empty;
|
||||
public string SponsorsContent { get; set; } = string.Empty;
|
||||
public string ShowactsUrl { get; set; } = string.Empty;
|
||||
public string ShowactsContent { get; set; } = string.Empty;
|
||||
public string StreamBannerEyebrow { get; set; } = "Das grosse Finale";
|
||||
public string StreamBannerTitle { get; set; } = "Award-Show Finale";
|
||||
public string StreamBannerText { get; set; } = string.Empty;
|
||||
public string StreamBannerLiveButtonLabel { get; set; } = "Jetzt live · Zum Stream";
|
||||
public string StreamBannerLiveButtonUrl { get; set; } = string.Empty;
|
||||
public string StreamBannerLockedButtonLabel { get; set; } = "Stream noch gesperrt";
|
||||
public bool StreamBannerUseCompletedContent { get; set; }
|
||||
public string StreamBannerCompletedEyebrow { get; set; } = "Danke fürs Mitfiebern";
|
||||
public string StreamBannerCompletedTitle { get; set; } = "Award-Show abgeschlossen";
|
||||
public string StreamBannerCompletedText { get; set; } = "Die grosse Award-Show ist vorbei. Danke an alle, die live dabei waren.";
|
||||
public string StreamBannerCompletedButtonLabel { get; set; } = "Highlights ansehen";
|
||||
public string StreamBannerCompletedButtonUrl { get; set; } = string.Empty;
|
||||
public string AwardsSectionTitle { get; set; } = string.Empty;
|
||||
public string AwardsSectionDescription { get; set; } = string.Empty;
|
||||
public string SubcategoriesSectionTitle { get; set; } = string.Empty;
|
||||
public string SubcategoriesSectionDescription { get; set; } = string.Empty;
|
||||
public string SocialLinksJson { get; set; } = "[]";
|
||||
public string FaqJson { get; set; } = "[]";
|
||||
public string RiskRulesJson { get; set; } = "[]";
|
||||
public string WorkflowRulesJson { get; set; } = "[]";
|
||||
public string TrackingRulesJson { get; set; } = "[]";
|
||||
public string ViewerStatsProviderBaseUrl { get; set; } = string.Empty;
|
||||
public string TrackingReviewNotes { get; set; } = string.Empty;
|
||||
public string NominationLinkBlacklistJson { get; set; } = "[]";
|
||||
public bool ClipSubmissionsEnabled { get; set; }
|
||||
public bool ClipReviewEnabled { get; set; } = true;
|
||||
public bool ClipAdminMenuVisible { get; set; } = true;
|
||||
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||
public bool ShowactApplicationsEnabled { get; set; }
|
||||
public DateOnly? ShowactApplicationStartsAt { get; set; }
|
||||
public DateOnly? ShowactApplicationEndsAt { get; set; }
|
||||
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
|
||||
public string ShowactFormSchemaJson { get; set; } = "[]";
|
||||
public bool SponsorsVisible { get; set; } = true;
|
||||
public bool DemoLoginManagedByDatabase { get; set; }
|
||||
public bool DemoLoginEnabled { get; set; }
|
||||
public string DemoLoginEmail { get; set; } = string.Empty;
|
||||
@@ -31,6 +66,7 @@ public sealed class SiteSettings
|
||||
public string TwitchClientSecret { get; set; } = string.Empty;
|
||||
public string TwitchRedirectUri { get; set; } = string.Empty;
|
||||
public string TwitchScope { get; set; } = string.Empty;
|
||||
public int SessionIdleTimeoutHours { get; set; } = 3;
|
||||
public bool MaintenanceModeEnabled { get; set; }
|
||||
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
||||
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class Sponsor
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SeasonId { get; set; }
|
||||
public Season Season { get; set; } = null!;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string WebsiteUrl { get; set; } = string.Empty;
|
||||
public string LogoUrl { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Tier { get; set; } = "Partner";
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsVisible { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class StreamerIdentity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
public string Login { get; set; } = string.Empty;
|
||||
public string NormalizedKey { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? ProfileUrl { get; set; }
|
||||
public DateTimeOffset? LastResolvedAt { get; set; }
|
||||
public ICollection<Nomination> Nominations { get; set; } = [];
|
||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -20,37 +21,39 @@ public static class AdminDashboardEndpoints
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDashboard(AwardsDbContext db, HttpContext context)
|
||||
private static async Task<IResult> GetDashboard(int? seasonId, AwardsDbContext db, HttpContext context)
|
||||
{
|
||||
var canViewAuditIp = CanViewAuditIp(context);
|
||||
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
|
||||
if (currentSeason is null)
|
||||
var selectedSeason = seasonId.HasValue
|
||||
? await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.Id == seasonId.Value)
|
||||
: await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
|
||||
if (selectedSeason 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.Status == "pending");
|
||||
var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
|
||||
var selectedSeasonId = selectedSeason.Id;
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(selectedSeason.CurrentPhase);
|
||||
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId);
|
||||
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == selectedSeasonId);
|
||||
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == selectedSeasonId);
|
||||
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId && item.Status == "pending");
|
||||
var riskFlagCount = await db.RiskFlags.CountAsync(item =>
|
||||
item.Status == "open" &&
|
||||
(item.SeasonId == selectedSeasonId || item.SeasonId == null));
|
||||
var globalRiskFlagCount = await db.RiskFlags.CountAsync(item =>
|
||||
item.Status == "open" &&
|
||||
item.SeasonId == 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(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count()))
|
||||
.OrderByDescending(item => item.Votes)
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
var topCategories = phaseKey == "nomination"
|
||||
? await BuildTopNominationCategoriesAsync(db, selectedSeasonId)
|
||||
: await BuildTopVotingCategoriesAsync(db, selectedSeasonId);
|
||||
|
||||
var riskFlags = await db.RiskFlags
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == "open")
|
||||
.Where(item =>
|
||||
item.Status == "open" &&
|
||||
(item.SeasonId == selectedSeasonId || item.SeasonId == null))
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(8)
|
||||
.ToArrayAsync();
|
||||
@@ -74,18 +77,27 @@ public static class AdminDashboardEndpoints
|
||||
.ToArrayAsync();
|
||||
|
||||
var activityItems = auditEntries
|
||||
.Take(3)
|
||||
.Take(6)
|
||||
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new AdminDashboardResponse(
|
||||
selectedSeason.Id,
|
||||
selectedSeason.Year,
|
||||
selectedSeason.Name,
|
||||
selectedSeason.IsCurrent,
|
||||
new[]
|
||||
{
|
||||
new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf"),
|
||||
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
|
||||
new AdminMetricDto("Nominierungen", nominationCount, $"Gespeicherte Einreichungen im Award-Jahr {selectedSeason.Year}"),
|
||||
new AdminMetricDto("Stimmen", voteCount, $"Abgegebene Stimmen im Award-Jahr {selectedSeason.Year}"),
|
||||
new AdminMetricDto("Kategorien", categoryCount, $"Aktive Kategorien im Award-Jahr {selectedSeason.Year}"),
|
||||
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf in diesem Jahr"),
|
||||
new AdminMetricDto(
|
||||
"Risikohinweise",
|
||||
riskFlagCount,
|
||||
globalRiskFlagCount > 0
|
||||
? $"Offene Hinweise fuer {selectedSeason.Year}, inklusive {globalRiskFlagCount} globaler Hinweise"
|
||||
: $"Offene Hinweise fuer {selectedSeason.Year}"),
|
||||
},
|
||||
activityItems,
|
||||
topCategories,
|
||||
@@ -93,6 +105,45 @@ public static class AdminDashboardEndpoints
|
||||
auditEntries));
|
||||
}
|
||||
|
||||
private static async Task<AdminTopCategoryDto[]> BuildTopVotingCategoriesAsync(AwardsDbContext db, int seasonId)
|
||||
{
|
||||
var categoryNames = await db.VoteEntries
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Ballot.SeasonId == seasonId)
|
||||
.Select(item => item.Category.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return categoryNames
|
||||
.GroupBy(name => name)
|
||||
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Stimmen"))
|
||||
.OrderByDescending(item => item.Value)
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<AdminTopCategoryDto[]> BuildTopNominationCategoriesAsync(AwardsDbContext db, int seasonId)
|
||||
{
|
||||
var nominationCategories = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new
|
||||
{
|
||||
CategoryName = item.Category != null ? item.Category.Name : null,
|
||||
item.CategoryGroupName,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return nominationCategories
|
||||
.Select(item => string.IsNullOrWhiteSpace(item.CategoryName)
|
||||
? string.IsNullOrWhiteSpace(item.CategoryGroupName) ? "Ohne Kategorie" : item.CategoryGroupName
|
||||
: item.CategoryName)
|
||||
.GroupBy(name => name)
|
||||
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Nominierungen"))
|
||||
.OrderByDescending(item => item.Value)
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAuditEntries(
|
||||
int? limit,
|
||||
string? query,
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class AdminEndpoints
|
||||
group.MapAdminDashboardEndpoints();
|
||||
group.MapAdminSeasonManagementEndpoints();
|
||||
group.MapAdminModerationEndpoints();
|
||||
group.MapAdminExtrasEndpoints();
|
||||
group.MapAdminTeamEndpoints();
|
||||
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static class AdminExtrasEndpoints
|
||||
{
|
||||
private static readonly string[] ContentPermissions = ["content", "settings"];
|
||||
private static readonly HashSet<string> AllowedShowactStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"pending",
|
||||
"shortlisted",
|
||||
"accepted",
|
||||
"rejected",
|
||||
};
|
||||
|
||||
public static RouteGroupBuilder MapAdminExtrasEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/seasons/{seasonId:int}/showacts", GetShowactApplications)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
|
||||
.WithName("GetAdminShowactApplications")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/showacts/{applicationId:int}/status", UpdateShowactStatus)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("UpdateAdminShowactStatus")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapDelete("/showacts/{applicationId:int}", DeleteShowactApplication)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("DeleteAdminShowactApplication")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{seasonId:int}/sponsors", GetSponsors)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
|
||||
.WithName("GetAdminSponsors")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/seasons/{seasonId:int}/sponsors", CreateSponsor)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("CreateAdminSponsor")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPut("/sponsors/{sponsorId:int}", UpdateSponsor)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("UpdateAdminSponsor")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapDelete("/sponsors/{sponsorId:int}", DeleteSponsor)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("DeleteAdminSponsor")
|
||||
.WithOpenApi();
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetShowactApplications(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var applications = await db.ShowactApplications
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.Status == "pending" ? 0 : 1)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Select(item => ToDto(item))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(applications);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateShowactStatus(
|
||||
HttpContext context,
|
||||
int applicationId,
|
||||
UpdateShowactStatusRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
|
||||
if (application is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var status = NormalizeStatus(request.Status);
|
||||
if (!AllowedShowactStatuses.Contains(status))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Status muss pending, shortlisted, accepted oder rejected sein." });
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var before = new { application.Status, application.ReviewNote };
|
||||
application.Status = status;
|
||||
application.ReviewNote = NormalizeText(request.ReviewNote, 500);
|
||||
application.ReviewedByTwitchId = session.TwitchUserId;
|
||||
application.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"showact.status",
|
||||
"showact-application",
|
||||
application.Id.ToString(),
|
||||
$"Showact-Bewerbung von {application.ArtistName} wurde auf {status} gesetzt.",
|
||||
new { before, after = new { application.Status, application.ReviewNote } },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, application = ToDto(application) });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteShowactApplication(
|
||||
HttpContext context,
|
||||
int applicationId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
|
||||
if (application is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
db.ShowactApplications.Remove(application);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"showact.delete",
|
||||
"showact-application",
|
||||
application.Id.ToString(),
|
||||
$"Showact-Bewerbung von {application.ArtistName} wurde geloescht.",
|
||||
new { application.ArtistName, application.ContactEmail, application.Status },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, applicationId });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSponsors(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var sponsors = await db.Sponsors
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.Select(item => ToDto(item))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(sponsors);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateSponsor(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertSponsorRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
if (!await db.Seasons.AnyAsync(item => item.Id == seasonId, context.RequestAborted))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var sponsor = new Sponsor { SeasonId = seasonId, CreatedAt = DateTimeOffset.UtcNow };
|
||||
var validation = ApplySponsorRequest(sponsor, request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
db.Sponsors.Add(sponsor);
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"sponsor.create",
|
||||
"sponsor",
|
||||
"new",
|
||||
$"Sponsor {sponsor.Name} wurde angelegt.",
|
||||
ToDto(sponsor),
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSponsor(
|
||||
HttpContext context,
|
||||
int sponsorId,
|
||||
UpsertSponsorRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
|
||||
if (sponsor is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToDto(sponsor);
|
||||
var validation = ApplySponsorRequest(sponsor, request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
sponsor.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"sponsor.update",
|
||||
"sponsor",
|
||||
sponsor.Id.ToString(),
|
||||
$"Sponsor {sponsor.Name} wurde aktualisiert.",
|
||||
new { before, after = ToDto(sponsor) },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteSponsor(
|
||||
HttpContext context,
|
||||
int sponsorId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
|
||||
if (sponsor is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
db.Sponsors.Remove(sponsor);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"sponsor.delete",
|
||||
"sponsor",
|
||||
sponsor.Id.ToString(),
|
||||
$"Sponsor {sponsor.Name} wurde geloescht.",
|
||||
ToDto(sponsor),
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, sponsorId });
|
||||
}
|
||||
|
||||
private static IResult? ApplySponsorRequest(Sponsor sponsor, UpsertSponsorRequest request)
|
||||
{
|
||||
var name = NormalizeText(request.Name, 120);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Sponsor-Name ist erforderlich." });
|
||||
}
|
||||
|
||||
var websiteUrl = NormalizeText(request.WebsiteUrl, 500);
|
||||
var logoUrl = NormalizeText(request.LogoUrl, 500);
|
||||
if (!IsBlankOrHttpUrl(websiteUrl) || !IsBlankOrHttpUrl(logoUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Sponsor-Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
sponsor.Name = name;
|
||||
sponsor.WebsiteUrl = websiteUrl;
|
||||
sponsor.LogoUrl = logoUrl;
|
||||
sponsor.Description = NormalizeText(request.Description, 500);
|
||||
sponsor.Tier = NormalizeText(request.Tier, 80);
|
||||
if (string.IsNullOrWhiteSpace(sponsor.Tier))
|
||||
{
|
||||
sponsor.Tier = "Partner";
|
||||
}
|
||||
|
||||
sponsor.SortOrder = Math.Clamp(request.SortOrder, 0, 9999);
|
||||
sponsor.IsVisible = request.IsVisible;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SponsorDto ToDto(Sponsor sponsor) =>
|
||||
new(
|
||||
sponsor.Id,
|
||||
sponsor.SeasonId,
|
||||
sponsor.Name,
|
||||
sponsor.WebsiteUrl,
|
||||
sponsor.LogoUrl,
|
||||
sponsor.Description,
|
||||
sponsor.Tier,
|
||||
sponsor.SortOrder,
|
||||
sponsor.IsVisible);
|
||||
|
||||
private static ShowactApplicationDto ToDto(ShowactApplication application) =>
|
||||
new(
|
||||
application.Id,
|
||||
application.SeasonId,
|
||||
application.ArtistName,
|
||||
application.ContactEmail,
|
||||
application.ContactDiscord,
|
||||
application.PlatformUrl,
|
||||
application.PerformanceType,
|
||||
application.Description,
|
||||
application.TechnicalNotes,
|
||||
application.ReferenceUrl,
|
||||
application.Status,
|
||||
application.ReviewNote,
|
||||
application.CreatedAt,
|
||||
application.ReviewedAt,
|
||||
application.FieldResponsesJson ?? "{}");
|
||||
|
||||
private static string NormalizeStatus(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string NormalizeText(string? value, int maxLength)
|
||||
{
|
||||
var trimmed = (value ?? string.Empty).Trim();
|
||||
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static bool IsBlankOrHttpUrl(string value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||
}
|
||||
@@ -20,6 +20,26 @@ public static partial class AdminModerationEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("ReopenRejectedAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("UpdateNominationTrackingReview")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("GetAdminNominationLinkBlacklist")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/nominations/link-blacklist", UpdateNominationLinkBlacklist)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("UpdateAdminNominationLinkBlacklist")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/link-blacklist", AddNominationLinkBlacklistEntry)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("AddAdminNominationLinkBlacklistEntry")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/risk-flags", GetRiskFlags)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||
.WithName("GetAdminRiskFlags")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminModerationEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetNominationLinkBlacklist(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateNominationLinkBlacklist(
|
||||
HttpContext context,
|
||||
UpdateNominationLinkBlacklistRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedEntries = NormalizeBlacklistEntries(request.Urls, out var invalidUrl);
|
||||
if (invalidUrl is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Blacklist-Link ist keine gueltige http(s)-URL: {invalidUrl}" });
|
||||
}
|
||||
|
||||
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(normalizedEntries);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination-link-blacklist.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Nominierungs-Link-Blacklist wurde aktualisiert.",
|
||||
new { count = normalizedEntries.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddNominationLinkBlacklistEntry(
|
||||
HttpContext context,
|
||||
AddNominationLinkBlacklistEntryRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(request.Url, out var normalizedUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Blacklist-Link ist keine gueltige http(s)-URL." });
|
||||
}
|
||||
|
||||
var entries = NominationLinkBlacklistSettings.Read(settings).ToList();
|
||||
if (!NominationLinkBlacklistSettings.IsBlocked(normalizedUrl, entries))
|
||||
{
|
||||
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
|
||||
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(entries);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"nomination-link-blacklist.add",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Link wurde zur Nominierungs-Blacklist hinzugefuegt.",
|
||||
new { url = normalizedUrl },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
|
||||
}
|
||||
|
||||
private static AdminNominationLinkBlacklistResponse ToNominationLinkBlacklistResponse(Backend.Domain.SiteSettings settings) =>
|
||||
new(NominationLinkBlacklistSettings.Read(settings)
|
||||
.Select(entry => new AdminNominationLinkBlacklistEntryDto(entry.Url))
|
||||
.ToArray());
|
||||
|
||||
private static NominationLinkBlacklistEntry[] NormalizeBlacklistEntries(string[]? urls, out string? invalidUrl)
|
||||
{
|
||||
invalidUrl = null;
|
||||
var entries = new List<NominationLinkBlacklistEntry>();
|
||||
foreach (var rawUrl in urls ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(rawUrl, out var normalizedUrl))
|
||||
{
|
||||
invalidUrl = rawUrl;
|
||||
return [];
|
||||
}
|
||||
|
||||
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
|
||||
}
|
||||
|
||||
return entries
|
||||
.DistinctBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static readonly string[] CandidateAcceptanceStatuses = ["open", "contacted", "accepted", "declined"];
|
||||
private static readonly string[] CandidateClipEmbedStatuses = ["unchecked", "embeddable", "link_only", "blocked"];
|
||||
|
||||
private static async Task<IResult> CreateCandidate(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
@@ -31,6 +34,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
var normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||
|
||||
if (normalizedClipUrl is null)
|
||||
{
|
||||
normalizedClipTitle = null;
|
||||
normalizedClipPlatform = null;
|
||||
normalizedClipEmbedStatus = "unchecked";
|
||||
}
|
||||
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
@@ -40,6 +57,21 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
seasonId,
|
||||
request.CategoryId,
|
||||
null,
|
||||
null,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
var candidate = new Candidate
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
@@ -47,6 +79,12 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
DisplayName = normalizedDisplayName,
|
||||
ChannelSlug = normalizedChannelSlug,
|
||||
Platform = request.Platform.Trim(),
|
||||
AcceptanceStatus = normalizedAcceptanceStatus,
|
||||
AcceptanceNote = normalizedAcceptanceNote,
|
||||
ClipCompilationUrl = normalizedClipUrl,
|
||||
ClipCompilationTitle = normalizedClipTitle,
|
||||
ClipCompilationPlatform = normalizedClipPlatform,
|
||||
ClipEmbedStatus = normalizedClipEmbedStatus,
|
||||
};
|
||||
|
||||
db.Candidates.Add(candidate);
|
||||
@@ -56,7 +94,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
"candidate",
|
||||
request.DisplayName.Trim(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.CategoryId, request.Platform },
|
||||
new { seasonId, request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -92,6 +130,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
var normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||
|
||||
if (normalizedClipUrl is null)
|
||||
{
|
||||
normalizedClipTitle = null;
|
||||
normalizedClipPlatform = null;
|
||||
normalizedClipEmbedStatus = "unchecked";
|
||||
}
|
||||
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == candidate.SeasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
@@ -102,10 +154,31 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
candidate.SeasonId,
|
||||
request.CategoryId,
|
||||
candidateId,
|
||||
candidate.StreamerIdentityId,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
candidate.CategoryId = request.CategoryId;
|
||||
candidate.DisplayName = normalizedDisplayName;
|
||||
candidate.ChannelSlug = normalizedChannelSlug;
|
||||
candidate.Platform = request.Platform.Trim();
|
||||
candidate.AcceptanceStatus = normalizedAcceptanceStatus;
|
||||
candidate.AcceptanceNote = normalizedAcceptanceNote;
|
||||
candidate.ClipCompilationUrl = normalizedClipUrl;
|
||||
candidate.ClipCompilationTitle = normalizedClipTitle;
|
||||
candidate.ClipCompilationPlatform = normalizedClipPlatform;
|
||||
candidate.ClipEmbedStatus = normalizedClipEmbedStatus;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -113,7 +186,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||
new { request.CategoryId, request.Platform },
|
||||
new { request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -146,4 +219,34 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, candidateId });
|
||||
}
|
||||
|
||||
private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection<string> allowedValues)
|
||||
{
|
||||
var normalized = value?.Trim().ToLowerInvariant();
|
||||
return !string.IsNullOrWhiteSpace(normalized) && allowedValues.Contains(normalized)
|
||||
? normalized
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalCandidateText(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalCandidateUrl(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new BadHttpRequestException("Compilation-Link muss eine gültige http(s)-URL sein.");
|
||||
}
|
||||
|
||||
return uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = request.ViewerRangeMin,
|
||||
ViewerRangeMax = request.ViewerRangeMax,
|
||||
};
|
||||
|
||||
db.Categories.Add(category);
|
||||
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = request.ViewerRangeMin;
|
||||
category.ViewerRangeMax = request.ViewerRangeMax;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Common;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> UpdateSeasonSubcategoryTemplates(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateSeasonSubcategoryTemplatesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateSubcategoryTemplatesRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
var blockedRemovals = await FindBlockedSubcategoryRemovalsAsync(db, categories, templates, context.RequestAborted);
|
||||
if (blockedRemovals.Length > 0)
|
||||
{
|
||||
var firstBlocked = blockedRemovals[0];
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = $"Unterkategorie \"{firstBlocked.SubcategoryName}\" kann nicht entfernt werden, weil darunter noch {firstBlocked.CandidateCount} Kandidaten und {firstBlocked.NominationCount} Nominierungen haengen.",
|
||||
blockedSubcategories = blockedRemovals,
|
||||
});
|
||||
}
|
||||
|
||||
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-templates.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Unterkategorien für {season.Year} wurden aktualisiert.",
|
||||
new { seasonId, templateCount = templates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, templateCount = templates.Length });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Lege zuerst mindestens eine globale Unterkategorie an." });
|
||||
}
|
||||
|
||||
var groupName = request.GroupName.Trim();
|
||||
if (categories.Any(item => string.Equals(item.GroupName, groupName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
categories.Add(new Category
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
GroupName = groupName,
|
||||
Name = templates[index].Name,
|
||||
Slug = BuildCategorySlug(groupName, templates[index].Slug),
|
||||
Description = request.Description.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||
ViewerRangeMin = templates[index].ViewerRangeMin,
|
||||
ViewerRangeMax = templates[index].ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var category in categories.Where(item => item.Id == 0))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.create",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {groupName} wurde angelegt.",
|
||||
new { seasonId, groupName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
UpsertCategoryGroupRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var validationError = ValidateCategoryGroupRequest(request);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToListAsync(context.RequestAborted);
|
||||
var templates = SeasonSubcategoryTemplateSettings.Read(season, categories);
|
||||
var normalizedCurrentName = groupName.Trim();
|
||||
var groupCategories = categories
|
||||
.Where(item => string.Equals(item.GroupName, normalizedCurrentName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var targetName = request.GroupName.Trim();
|
||||
if (!string.Equals(normalizedCurrentName, targetName, StringComparison.OrdinalIgnoreCase)
|
||||
&& categories.Any(item => string.Equals(item.GroupName, targetName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." });
|
||||
}
|
||||
|
||||
foreach (var category in groupCategories)
|
||||
{
|
||||
category.GroupName = targetName;
|
||||
category.Description = request.Description.Trim();
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
}
|
||||
|
||||
SyncCategoryGroupsToTemplates(db, season, categories, templates);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.update",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedCurrentName} wurde aktualisiert.",
|
||||
new { seasonId, from = normalizedCurrentName, to = targetName, request.SortOrder, request.MaxNomineesPerUser },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, seasonId, groupName = targetName });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCategoryGroup(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
string groupName,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var normalizedGroupName = groupName.Trim();
|
||||
var categories = await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName.ToLower() == normalizedGroupName.ToLower())
|
||||
.ToListAsync(context.RequestAborted);
|
||||
if (categories.Count == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var categoryIds = categories.Select(item => item.Id).ToArray();
|
||||
var candidates = await db.Candidates
|
||||
.Where(item => categoryIds.Contains(item.CategoryId))
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
|
||||
db.Categories.RemoveRange(categories);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"category-group.delete",
|
||||
"season",
|
||||
seasonId.ToString(),
|
||||
$"Hauptkategorie {normalizedGroupName} wurde gelöscht.",
|
||||
new { seasonId, removedCategories = categories.Count, removedCandidates = candidates.Length },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, seasonId, groupName = normalizedGroupName });
|
||||
}
|
||||
|
||||
private static void SyncCategoryGroupsToTemplates(
|
||||
AwardsDbContext db,
|
||||
Season season,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates)
|
||||
{
|
||||
var orderedGroups = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var items = group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList();
|
||||
var sample = items[0];
|
||||
return new
|
||||
{
|
||||
GroupName = sample.GroupName.Trim(),
|
||||
Description = sample.Description.Trim(),
|
||||
SortOrder = items.Min(item => item.SortOrder),
|
||||
MaxNomineesPerUser = sample.MaxNomineesPerUser,
|
||||
Items = items,
|
||||
};
|
||||
})
|
||||
.OrderBy(group => group.SortOrder)
|
||||
.ThenBy(group => group.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var nextSortOrder = 1;
|
||||
foreach (var group in orderedGroups)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
for (var index = 0; index < templates.Length; index += 1)
|
||||
{
|
||||
var template = templates[index];
|
||||
var category = FindReusableCategoryForTemplate(group.Items, template, group.GroupName, usedCategories)
|
||||
?? new Category { SeasonId = season.Id };
|
||||
usedCategories.Add(category);
|
||||
|
||||
category.GroupName = group.GroupName;
|
||||
category.Name = template.Name;
|
||||
category.Slug = BuildCategorySlug(group.GroupName, template.Slug);
|
||||
category.Description = group.Description;
|
||||
category.MaxNomineesPerUser = group.MaxNomineesPerUser;
|
||||
category.ViewerRangeMin = template.ViewerRangeMin;
|
||||
category.ViewerRangeMax = template.ViewerRangeMax;
|
||||
category.SortOrder = nextSortOrder++;
|
||||
|
||||
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
|
||||
{
|
||||
db.Categories.Add(category);
|
||||
categories.Add(category);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var staleCategory in FindStaleCategoriesAfterTemplateSync(group.Items, templates, group.GroupName))
|
||||
{
|
||||
RemoveCategoryWithCandidates(db, staleCategory);
|
||||
categories.Remove(staleCategory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveCategoryWithCandidates(AwardsDbContext db, Category category)
|
||||
{
|
||||
if (category.Id > 0)
|
||||
{
|
||||
var candidates = db.Candidates.Where(item => item.CategoryId == category.Id).ToArray();
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
db.Candidates.RemoveRange(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
db.Categories.Remove(category);
|
||||
}
|
||||
|
||||
private static async Task<BlockedSubcategoryRemoval[]> FindBlockedSubcategoryRemovalsAsync(
|
||||
AwardsDbContext db,
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var staleCategories = categories
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.GroupName))
|
||||
.GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(group => FindStaleCategoriesAfterTemplateSync(
|
||||
group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(),
|
||||
templates,
|
||||
group.Key))
|
||||
.Where(item => item.Id > 0)
|
||||
.ToArray();
|
||||
|
||||
if (staleCategories.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
|
||||
var candidateCounts = await db.Candidates
|
||||
.Where(item => staleCategoryIds.Contains(item.CategoryId))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
var nominationCounts = await db.Nominations
|
||||
.Where(item =>
|
||||
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|
||||
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value)))
|
||||
.GroupBy(item => item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)
|
||||
? item.CategoryId!.Value
|
||||
: item.SuggestedCategoryId!.Value)
|
||||
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
|
||||
|
||||
return staleCategories
|
||||
.Select(category => new BlockedSubcategoryRemoval(
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Slug,
|
||||
candidateCounts.GetValueOrDefault(category.Id),
|
||||
nominationCounts.GetValueOrDefault(category.Id)))
|
||||
.Where(item => item.CandidateCount > 0 || item.NominationCount > 0)
|
||||
.OrderBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.SubcategoryName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Category? FindReusableCategoryForTemplate(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting template,
|
||||
string groupName,
|
||||
HashSet<Category>? usedCategories = null)
|
||||
{
|
||||
usedCategories ??= [];
|
||||
var expectedSlug = BuildCategorySlug(groupName, template.Slug);
|
||||
var normalizedTemplateSlug = SeasonSubcategoryTemplateSettings.Slugify(template.Slug);
|
||||
|
||||
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, expectedSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Slug, normalizedTemplateSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& item.Slug.EndsWith($"-{normalizedTemplateSlug}", StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
|
||||
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static Category[] FindStaleCategoriesAfterTemplateSync(
|
||||
List<Category> categories,
|
||||
SeasonSubcategoryTemplateSetting[] templates,
|
||||
string groupName)
|
||||
{
|
||||
var usedCategories = new HashSet<Category>();
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var reusableCategory = FindReusableCategoryForTemplate(categories, template, groupName, usedCategories);
|
||||
if (reusableCategory is not null)
|
||||
{
|
||||
usedCategories.Add(reusableCategory);
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
.Where(item => !usedCategories.Contains(item))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IResult? ValidateSubcategoryTemplatesRequest(UpdateSeasonSubcategoryTemplatesRequest request)
|
||||
{
|
||||
var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates);
|
||||
if (templates.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Mindestens eine Unterkategorie ist erforderlich." });
|
||||
}
|
||||
|
||||
var duplicateNames = templates
|
||||
.GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateNames)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Namen mehrfach verwenden." });
|
||||
}
|
||||
|
||||
var duplicateSlugs = templates
|
||||
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateSlugs)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Slug mehrfach verwenden." });
|
||||
}
|
||||
|
||||
foreach (var template in templates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Name ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(template.Slug) || template.Slug.Length > 120)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unterkategorie-Slug ist erforderlich und muss unter 120 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (template.ViewerRangeMax is not null
|
||||
&& template.ViewerRangeMin is not null
|
||||
&& template.ViewerRangeMax < template.ViewerRangeMin)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer-Range Ende muss groesser oder gleich dem Start sein." });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult? ValidateCategoryGroupRequest(UpsertCategoryGroupRequest request)
|
||||
{
|
||||
var groupName = request.GroupName.Trim();
|
||||
var description = request.Description.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > 80)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Hauptkategorie ist erforderlich und muss unter 80 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (description.Length > 400)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Beschreibung muss unter 400 Zeichen bleiben." });
|
||||
}
|
||||
|
||||
if (request.SortOrder is < 1 or > 200)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Die Reihenfolge muss zwischen 1 und 200 liegen." });
|
||||
}
|
||||
|
||||
if (request.MaxNomineesPerUser is < 1 or > 10)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das Nominierungs-Limit muss zwischen 1 und 10 liegen." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record BlockedSubcategoryRemoval(
|
||||
string GroupName,
|
||||
string SubcategoryName,
|
||||
string Slug,
|
||||
int CandidateCount,
|
||||
int NominationCount);
|
||||
|
||||
private static string BuildCategorySlug(string groupName, string templateSlug)
|
||||
{
|
||||
var groupSlug = SeasonSubcategoryTemplateSettings.Slugify(groupName);
|
||||
var detailSlug = SeasonSubcategoryTemplateSettings.Slugify(templateSlug);
|
||||
return string.IsNullOrWhiteSpace(groupSlug) ? detailSlug : $"{groupSlug}-{detailSlug}";
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,16 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
|
||||
}
|
||||
|
||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings));
|
||||
|
||||
var season = new Season
|
||||
{
|
||||
Year = request.Year,
|
||||
Name = request.Name.Trim(),
|
||||
ShowStreamUrl = showStreamUrl,
|
||||
IsDemo = false,
|
||||
CurrentPhase = request.CurrentPhase.Trim(),
|
||||
IsCurrent = request.IsCurrent,
|
||||
IsCommunityOnly = request.IsCommunityOnly,
|
||||
@@ -45,6 +48,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
ReviewEndsAt = request.ReviewEndsAt,
|
||||
ShowDate = request.ShowDate,
|
||||
ShowStartsAt = request.ShowStartsAt,
|
||||
SubcategoryTemplatesJson = "[]",
|
||||
WorkflowRulesJson = initialWorkflowRulesJson,
|
||||
};
|
||||
|
||||
db.Seasons.Add(season);
|
||||
@@ -72,6 +77,13 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
copiedCategoryCount = sourceCategories.Length;
|
||||
var sourceSeason = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||
season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson;
|
||||
season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson)
|
||||
? initialWorkflowRulesJson
|
||||
: sourceSeason.WorkflowRulesJson;
|
||||
foreach (var category in sourceCategories)
|
||||
{
|
||||
db.Categories.Add(new Category
|
||||
@@ -83,6 +95,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
Description = category.Description,
|
||||
SortOrder = category.SortOrder,
|
||||
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -110,7 +124,6 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
request.IsCurrent,
|
||||
request.IsCommunityOnly,
|
||||
showStreamUrl,
|
||||
request.CurrentPhase,
|
||||
request.ShowDate,
|
||||
request.ShowStartsAt,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||
|
||||
var candidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -24,9 +30,17 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new AdminCandidateItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.Platform))
|
||||
item.Platform,
|
||||
item.NominationTally,
|
||||
item.AcceptanceStatus,
|
||||
item.AcceptanceNote,
|
||||
item.ClipCompilationUrl,
|
||||
item.ClipCompilationTitle,
|
||||
item.ClipCompilationPlatform,
|
||||
item.ClipEmbedStatus))
|
||||
.ToArrayAsync();
|
||||
|
||||
var candidateCounts = candidates
|
||||
@@ -47,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
|
||||
season,
|
||||
categoryRows.Select(category => new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
}));
|
||||
var visibleCategoryRows = categoryRows
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
|
||||
new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
},
|
||||
subcategoryTemplateSettings))
|
||||
.ToArray();
|
||||
|
||||
var categories = categoryRows
|
||||
var categories = visibleCategoryRows
|
||||
.Select(category => new AdminCategoryItemDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
@@ -59,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominations = await db.Nominations
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||
|
||||
var pendingNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? string.Empty,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -82,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = await db.Nominations
|
||||
var pendingNominations = pendingNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
|
||||
|
||||
var reviewedNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -102,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = reviewedNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var resultItems = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -112,11 +202,29 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CandidateId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.Platform))
|
||||
.ToArrayAsync();
|
||||
|
||||
var workflowRules = WorkflowRuleSettings.Read(season, settings);
|
||||
var votingEntryRows = await db.VoteEntries
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Category.SeasonId == seasonId)
|
||||
.Select(item => new AdminVotingEntryRow(
|
||||
item.BallotId,
|
||||
item.CategoryId,
|
||||
item.CandidateId))
|
||||
.ToArrayAsync();
|
||||
var votingWorkspace = BuildVotingWorkspace(
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations,
|
||||
resultItems,
|
||||
votingEntryRows,
|
||||
workflowRules);
|
||||
|
||||
var clipSubmissions = await db.ClipSubmissions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -141,7 +249,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
season.Id,
|
||||
season.Year,
|
||||
season.Name,
|
||||
NormalizeSeasonStreamUrl(season.ShowStreamUrl),
|
||||
season.IsDemo,
|
||||
season.CurrentPhase,
|
||||
season.IsCurrent,
|
||||
season.IsCommunityOnly,
|
||||
@@ -153,11 +261,466 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
season.ReviewEndsAt,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
season.WinnersPublishedAt,
|
||||
season.WinnersPublishedByTwitchId,
|
||||
subcategoryTemplates,
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations,
|
||||
pendingNominationGroups,
|
||||
reviewedNominations,
|
||||
settings?.TrackingReviewNotes ?? string.Empty,
|
||||
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||
resultItems,
|
||||
votingWorkspace,
|
||||
clipSubmissions));
|
||||
}
|
||||
|
||||
private sealed record AdminVotingEntryRow(
|
||||
int BallotId,
|
||||
int CategoryId,
|
||||
int CandidateId);
|
||||
|
||||
private sealed record AdminNominationRow(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string? CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? HoursStreamed,
|
||||
int? HoursWatched,
|
||||
int? PeakViewers,
|
||||
int? FollowersGained,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
string TrackingFlagsJson,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
string? CandidateDisplayName,
|
||||
string? ReviewNote,
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
private static AdminNominationReviewItemDto ToNominationReviewItem(
|
||||
AdminNominationRow item,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
|
||||
.Select(ToTrackingFlagHitDto)
|
||||
.ToArray();
|
||||
|
||||
return new(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
ResolveCategoryGroupName(item, categoryRows),
|
||||
ResolveCategoryName(item, categoryRows),
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryName,
|
||||
item.StreamerIdentityId,
|
||||
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
|
||||
trackingFlags.Any(flag => flag.RequiresManualReview),
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(item, trackingRules),
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
item.CandidateDisplayName,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt);
|
||||
}
|
||||
|
||||
private static AdminVotingWorkspaceDto BuildVotingWorkspace(
|
||||
AdminCategoryItemDto[] categories,
|
||||
AdminCandidateItemDto[] candidates,
|
||||
AdminNominationReviewItemDto[] pendingNominations,
|
||||
AdminAwardResultItemDto[] results,
|
||||
AdminVotingEntryRow[] voteEntries,
|
||||
WorkflowRuleSetting[] workflowRules)
|
||||
{
|
||||
var resultMap = results.ToDictionary(item => item.CategoryId);
|
||||
var totalBallots = voteEntries.Select(item => item.BallotId).Distinct().Count();
|
||||
var voteCountByCategory = voteEntries
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.ToDictionary(group => group.Key, group => group.Count());
|
||||
var ballotCountByCategory = voteEntries
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.ToDictionary(group => group.Key, group => group.Select(item => item.BallotId).Distinct().Count());
|
||||
var voteCountByCandidate = voteEntries
|
||||
.GroupBy(item => (item.CategoryId, item.CandidateId))
|
||||
.ToDictionary(group => group.Key, group => group.Count());
|
||||
|
||||
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||
var recommendedNominatorsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.RecommendedNominatorsPerSubcategory);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
|
||||
var workspaceItems = categories
|
||||
.Select(category =>
|
||||
{
|
||||
var categoryCandidates = candidates
|
||||
.Where(candidate => candidate.CategoryId == category.Id)
|
||||
.ToArray();
|
||||
var categoryVoteCount = voteCountByCategory.TryGetValue(category.Id, out var voteCount) ? voteCount : 0;
|
||||
var categoryBallotCount = ballotCountByCategory.TryGetValue(category.Id, out var ballotCount) ? ballotCount : 0;
|
||||
var nominationCount = categoryCandidates.Sum(candidate => Math.Max(candidate.NominationTally, 0));
|
||||
var openReviewCount = pendingNominations.Count(item =>
|
||||
item.CategoryId == category.Id
|
||||
|| item.SuggestedCategoryId == category.Id);
|
||||
var existingResult = resultMap.GetValueOrDefault(category.Id);
|
||||
var maxVotes = categoryCandidates.Length == 0
|
||||
? 0
|
||||
: categoryCandidates.Max(candidate => voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0));
|
||||
var topVoteTieCount = maxVotes <= 0
|
||||
? 0
|
||||
: categoryCandidates.Count(candidate => voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0) == maxVotes);
|
||||
|
||||
var leaderboard = categoryCandidates
|
||||
.Select(candidate =>
|
||||
{
|
||||
var candidateVotes = voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0);
|
||||
var hasWinnerConflict = CandidateHasWinnerConflict(candidate, category.Id, results, winnerPlacementsRule);
|
||||
return new AdminVotingCandidateRankDto(
|
||||
candidate.Id,
|
||||
candidate.DisplayName,
|
||||
candidate.ChannelSlug,
|
||||
candidate.Platform,
|
||||
candidateVotes,
|
||||
categoryVoteCount > 0 ? (int)Math.Round(candidateVotes * 100d / categoryVoteCount) : 0,
|
||||
Math.Max(candidate.NominationTally, 0),
|
||||
!string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl),
|
||||
string.IsNullOrWhiteSpace(candidate.ClipEmbedStatus) ? "unchecked" : candidate.ClipEmbedStatus,
|
||||
hasWinnerConflict,
|
||||
existingResult?.CandidateId == candidate.Id,
|
||||
!string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase),
|
||||
maxVotes > 0 && candidateVotes == maxVotes && topVoteTieCount > 1);
|
||||
})
|
||||
.OrderByDescending(item => item.Votes)
|
||||
.ThenByDescending(item => item.NominationTally)
|
||||
.ThenBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var readyCandidates = leaderboard.Count(item => item.IsAccepted);
|
||||
var leadingCandidate = leaderboard.FirstOrDefault();
|
||||
var hasMissingClip = winnerRequiresClipRule.Enabled
|
||||
&& leadingCandidate is not null
|
||||
&& !leadingCandidate.HasClip;
|
||||
var hasRuleConflict = leadingCandidate?.HasWinnerConflict ?? false;
|
||||
var hasOpenReviews = openReviewCount > 0;
|
||||
var hasSoftNominatorWarning = recommendedNominatorsRule.Enabled
|
||||
&& nominationCount < Math.Max(1, recommendedNominatorsRule.Limit);
|
||||
var winnerReady = existingResult is not null
|
||||
|| leadingCandidate is not null
|
||||
&& leadingCandidate.IsAccepted
|
||||
&& !hasMissingClip
|
||||
&& !hasRuleConflict
|
||||
&& !hasOpenReviews;
|
||||
|
||||
return new AdminVotingCategoryWorkspaceItemDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.SortOrder,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
categoryVoteCount,
|
||||
categoryBallotCount,
|
||||
categoryCandidates.Length,
|
||||
readyCandidates,
|
||||
nominationCount,
|
||||
openReviewCount,
|
||||
existingResult is not null,
|
||||
winnerReady,
|
||||
topVoteTieCount > 1,
|
||||
hasMissingClip,
|
||||
hasRuleConflict,
|
||||
hasOpenReviews,
|
||||
hasSoftNominatorWarning,
|
||||
leaderboard);
|
||||
})
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.CategoryName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var summary = new AdminVotingWorkspaceSummaryDto(
|
||||
voteEntries.Length,
|
||||
totalBallots,
|
||||
workspaceItems.Length,
|
||||
workspaceItems.Count(item => item.VoteCount > 0),
|
||||
workspaceItems.Count(item => item.WinnerReady),
|
||||
workspaceItems.Count(item =>
|
||||
item.HasMissingClip
|
||||
|| item.HasRuleConflict
|
||||
|| item.HasOpenReviews
|
||||
|| item.HasTopVoteTie),
|
||||
workspaceItems.Count(item => item.HasWinner));
|
||||
|
||||
return new AdminVotingWorkspaceDto(summary, workspaceItems);
|
||||
}
|
||||
|
||||
private static bool CandidateHasWinnerConflict(
|
||||
AdminCandidateItemDto candidate,
|
||||
int categoryId,
|
||||
AdminAwardResultItemDto[] results,
|
||||
WorkflowRuleSetting winnerPlacementsRule)
|
||||
{
|
||||
if (!winnerPlacementsRule.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var identityKey = candidate.StreamerIdentityId.HasValue
|
||||
? $"identity:{candidate.StreamerIdentityId.Value}"
|
||||
: WorkflowRuleSettings.CandidateIdentityKey(candidate.DisplayName, candidate.ChannelSlug);
|
||||
|
||||
var existingWinnerCount = results.Count(result =>
|
||||
{
|
||||
if (result.CategoryId == categoryId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var resultIdentityKey = result.StreamerIdentityId.HasValue
|
||||
? $"identity:{result.StreamerIdentityId.Value}"
|
||||
: WorkflowRuleSettings.CandidateIdentityKey(result.CandidateDisplayName, result.CandidateChannelSlug);
|
||||
return string.Equals(resultIdentityKey, identityKey, StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
return existingWinnerCount >= winnerPlacementsRule.Limit;
|
||||
}
|
||||
|
||||
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
|
||||
IEnumerable<AdminNominationRow> rows,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
rows
|
||||
.GroupBy(item => new
|
||||
{
|
||||
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
|
||||
IdentityKey = item.StreamerIdentityId.HasValue
|
||||
? $"identity:{item.StreamerIdentityId.Value}"
|
||||
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
|
||||
})
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
|
||||
var representative = ordered
|
||||
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
|
||||
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
|
||||
.ThenByDescending(item => item.AvgViewers.HasValue)
|
||||
.First();
|
||||
var trackerStatus = ResolveGroupTrackerStatus(ordered);
|
||||
var trackingFlags = ordered
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.GroupBy(item => item.Key)
|
||||
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
|
||||
.ToArray();
|
||||
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
|
||||
return new AdminNominationReviewGroupDto(
|
||||
representative.Id,
|
||||
ordered.Select(item => item.Id).ToArray(),
|
||||
ResolveCategoryGroupName(representative, categoryRows),
|
||||
ResolveNominationDisplayName(representative),
|
||||
representative.StreamUrl,
|
||||
representative.ResolvedChannel,
|
||||
representative.ResolvedPlatform,
|
||||
representative.AvgViewers,
|
||||
representative.SuggestedCategoryId,
|
||||
representative.SuggestedCategoryName,
|
||||
representative.StreamerIdentityId,
|
||||
trackerStatus,
|
||||
representative.TrackerCheckedAt,
|
||||
ResolveGroupTrackingReviewStatus(ordered),
|
||||
requiresManualReview,
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(representative, trackingRules),
|
||||
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
|
||||
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
|
||||
ordered.Max(item => item.TrackingReviewedAt),
|
||||
ordered.Length,
|
||||
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
|
||||
ordered.First().CreatedAt,
|
||||
ordered.Last().CreatedAt);
|
||||
})
|
||||
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
|
||||
.ThenByDescending(item => item.RequiresManualReview)
|
||||
.ThenByDescending(item => item.NominationTally)
|
||||
.ThenByDescending(item => item.UniqueSubmitterCount)
|
||||
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
|
||||
.ThenByDescending(item => item.AvgViewers ?? -1)
|
||||
.ThenByDescending(item => item.LastSubmittedAt)
|
||||
.ToArray();
|
||||
|
||||
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
|
||||
item.ResolvedChannel
|
||||
?? item.CandidateText
|
||||
?? item.StreamUrl
|
||||
?? "Name im Review festlegen";
|
||||
|
||||
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackerStatus
|
||||
?? "pending";
|
||||
}
|
||||
|
||||
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackingReviewStatus
|
||||
?? "clear";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
|
||||
{
|
||||
return item.CategoryGroupName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.GroupName ?? "Unbekannte Hauptkategorie";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryName))
|
||||
{
|
||||
return item.CategoryName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
|
||||
}
|
||||
|
||||
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
|
||||
new(
|
||||
flag.Key,
|
||||
flag.Label,
|
||||
flag.Severity,
|
||||
flag.Description,
|
||||
flag.RequiresManualReview,
|
||||
flag.BlocksApproval,
|
||||
flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
|
||||
AdminNominationRow row,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
trackingRules.ImportantMetrics
|
||||
.Concat(trackingRules.OptionalMetrics)
|
||||
.Where(metric => metric.Enabled && metric.ShowInReview)
|
||||
.Select(metric => new AdminTrackingMetricStateDto(
|
||||
metric.Key,
|
||||
metric.Label,
|
||||
metric.RequiredForAutoClassification,
|
||||
metric.SourceSupport,
|
||||
MetricPresent(metric, row),
|
||||
MetricValue(metric, row),
|
||||
metric.Description,
|
||||
metric.WindowKey,
|
||||
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||
.ToArray();
|
||||
|
||||
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
|
||||
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
|
||||
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
|
||||
_ => "manuell",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (metric.TopCount.HasValue)
|
||||
{
|
||||
parts.Add($"Top {metric.TopCount.Value}");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategorySharePercent.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategoryHours.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
|
||||
{
|
||||
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
|
||||
}
|
||||
|
||||
if (metric.IgnoredCategories.Length > 0)
|
||||
{
|
||||
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
|
||||
}
|
||||
|
||||
return parts.Count > 0
|
||||
? string.Join(" · ", parts)
|
||||
: "Top-Kategorien manuell pruefen";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.Name,
|
||||
item.CurrentPhase,
|
||||
item.IsCurrent,
|
||||
item.Categories.Count))
|
||||
item.IsDemo,
|
||||
item.Categories.Count,
|
||||
item.WinnersPublishedAt,
|
||||
item.WinnersPublishedByTwitchId))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(seasons);
|
||||
|
||||
@@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategory")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminSeasonSubcategoryTemplates")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("CreateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategoryGroup")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||
.WithName("CreateAdminCandidate")
|
||||
@@ -56,6 +72,22 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("DeleteAdminResult")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/winners/publish", PublishWinners)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("PublishAdminSeasonWinners")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/winners/unpublish", UnpublishWinners)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("UnpublishAdminSeasonWinners")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminWorkflowRules")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -14,6 +16,40 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
private const int MaxCandidateDisplayNameLength = 120;
|
||||
private const int MaxCandidateChannelSlugLength = 120;
|
||||
private const int MaxCandidatePlatformLength = 60;
|
||||
private const int MaxCandidateAcceptanceNoteLength = 500;
|
||||
private const int MaxCandidateClipUrlLength = 500;
|
||||
private const int MaxCandidateClipTitleLength = 200;
|
||||
private const int MaxCandidateClipPlatformLength = 40;
|
||||
|
||||
private sealed record CandidateRuleSnapshot(
|
||||
int Id,
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string AcceptanceStatus);
|
||||
|
||||
private sealed record CandidateReadinessSnapshot(
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string AcceptanceStatus,
|
||||
string? ClipCompilationUrl);
|
||||
|
||||
private sealed record WinnerReadinessSnapshot(
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string? ClipCompilationUrl);
|
||||
|
||||
private sealed record WinnerPublicationSnapshot(
|
||||
int CategoryId,
|
||||
int? StreamerIdentityId,
|
||||
string DisplayName,
|
||||
string ChannelSlug,
|
||||
string? ClipCompilationUrl);
|
||||
|
||||
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||
{
|
||||
@@ -79,11 +115,6 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeSeasonStreamUrl(string? showStreamUrl)
|
||||
{
|
||||
return SeasonMappings.NormalizeSeasonStreamUrl(showStreamUrl);
|
||||
}
|
||||
|
||||
private static bool IsKnownSeasonPhase(string? currentPhase)
|
||||
{
|
||||
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
@@ -127,11 +158,101 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
var issueList = issues.ToArray();
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = $"Public-/Archiv-Readiness blockiert: {string.Join(" ", issueList)}",
|
||||
message = $"Landingpage-Freigabe blockiert: {string.Join(" ", issueList)}",
|
||||
issues = issueList,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult CreateWinnerPublicationError(IEnumerable<string> issues)
|
||||
{
|
||||
var issueList = issues.ToArray();
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = $"Gewinner-Freigabe blockiert: {string.Join(" ", issueList)}",
|
||||
issues = issueList,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
return WorkflowRuleSettings.Read(season, settings);
|
||||
}
|
||||
|
||||
private static IResult CreateWorkflowRuleError(string message) =>
|
||||
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||
|
||||
private static async Task<IResult?> BuildCandidateWorkflowRuleBlockAsync(
|
||||
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 rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||
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 CandidateRuleSnapshot(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||
{
|
||||
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||
if (categoryCount >= finalistsRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
$"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 CreateWorkflowRuleError(
|
||||
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string[] BuildNewSeasonReadinessIssues(
|
||||
string currentPhase,
|
||||
bool isCurrent,
|
||||
@@ -171,6 +292,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
bool isCurrent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||
if (season is null)
|
||||
{
|
||||
return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."];
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||
@@ -179,6 +308,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return [];
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||
var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
|
||||
var categoryIds = await db.Categories
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -193,21 +327,57 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
var categoriesWithCandidates = await db.Candidates
|
||||
var candidateSnapshots = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new CandidateReadinessSnapshot(
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus,
|
||||
item.ClipCompilationUrl))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var activeCandidates = candidateSnapshots
|
||||
.Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var categoriesWithCandidates = activeCandidates
|
||||
.Select(item => item.CategoryId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
.Count();
|
||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||
if (emptyCategories > 0)
|
||||
{
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten.");
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||
{
|
||||
var identityOverflow = activeCandidates
|
||||
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||
})
|
||||
.Where(item => item.Count > appearancesRule.Limit)
|
||||
.OrderByDescending(item => item.Count)
|
||||
.FirstOrDefault();
|
||||
if (identityOverflow is not null)
|
||||
{
|
||||
issues.Add(
|
||||
$"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWinnerReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now))
|
||||
{
|
||||
issues.Add("Die Award Show liegt noch nicht in der Vergangenheit.");
|
||||
}
|
||||
|
||||
var categoriesWithResults = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -219,11 +389,141 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||
}
|
||||
|
||||
var resultSnapshots = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new WinnerReadinessSnapshot(
|
||||
item.CategoryId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.ClipCompilationUrl))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
||||
{
|
||||
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
||||
if (missingWinnerClipCount > 0)
|
||||
{
|
||||
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
||||
}
|
||||
}
|
||||
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||
{
|
||||
var winnerOverflow = resultSnapshots
|
||||
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||
})
|
||||
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
||||
.OrderByDescending(item => item.Count)
|
||||
.FirstOrDefault();
|
||||
if (winnerOverflow is not null)
|
||||
{
|
||||
issues.Add(
|
||||
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<string[]> BuildWinnerPublicationIssuesAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var categoryIds = await db.Categories
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => item.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var issues = new List<string>();
|
||||
if (categoryIds.Length == 0)
|
||||
{
|
||||
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
||||
return issues.ToArray();
|
||||
}
|
||||
|
||||
var resultSnapshots = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.Select(item => new WinnerPublicationSnapshot(
|
||||
item.CategoryId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.ClipCompilationUrl))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var categoriesWithResults = resultSnapshots
|
||||
.Select(item => item.CategoryId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults);
|
||||
if (missingResults > 0)
|
||||
{
|
||||
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||
}
|
||||
|
||||
var openReviewCount = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.CountAsync(item => item.SeasonId == seasonId && item.Status == "pending", cancellationToken);
|
||||
if (openReviewCount > 0)
|
||||
{
|
||||
issues.Add($"{openReviewCount} Nominierungs-Review(s) sind noch offen.");
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
||||
{
|
||||
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
||||
if (missingWinnerClipCount > 0)
|
||||
{
|
||||
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
||||
}
|
||||
}
|
||||
|
||||
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||
{
|
||||
var winnerOverflow = resultSnapshots
|
||||
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||
})
|
||||
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
||||
.OrderByDescending(item => item.Count)
|
||||
.FirstOrDefault();
|
||||
if (winnerOverflow is not null)
|
||||
{
|
||||
issues.Add(
|
||||
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
return issues.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug)
|
||||
{
|
||||
if (streamerIdentityId.HasValue)
|
||||
{
|
||||
return $"identity:{streamerIdentityId.Value}";
|
||||
}
|
||||
|
||||
return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
}
|
||||
|
||||
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
||||
{
|
||||
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
||||
@@ -277,6 +577,21 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMin is < 0 or > 100000)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMax is < 0 or > 100000)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." });
|
||||
}
|
||||
|
||||
if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -311,6 +626,51 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." });
|
||||
}
|
||||
|
||||
if (!IsAllowedCandidateChoice(request.AcceptanceStatus, CandidateAcceptanceStatuses))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Acceptance status must be open, contacted, accepted, or declined." });
|
||||
}
|
||||
|
||||
if (!IsAllowedCandidateChoice(request.ClipEmbedStatus, CandidateClipEmbedStatuses))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Clip embed status must be unchecked, embeddable, link_only, or blocked." });
|
||||
}
|
||||
|
||||
if (request.AcceptanceNote?.Trim().Length > MaxCandidateAcceptanceNoteLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Acceptance note must stay below {MaxCandidateAcceptanceNoteLength} characters." });
|
||||
}
|
||||
|
||||
var clipUrl = request.ClipCompilationUrl?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(clipUrl))
|
||||
{
|
||||
if (clipUrl.Length > MaxCandidateClipUrlLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Compilation link must stay below {MaxCandidateClipUrlLength} characters." });
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Compilation link must be a valid http(s) URL." });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.ClipCompilationTitle?.Trim().Length > MaxCandidateClipTitleLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Compilation title must stay below {MaxCandidateClipTitleLength} characters." });
|
||||
}
|
||||
|
||||
if (request.ClipCompilationPlatform?.Trim().Length > MaxCandidateClipPlatformLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Compilation platform must stay below {MaxCandidateClipPlatformLength} characters." });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsAllowedCandidateChoice(string? value, IReadOnlyCollection<string> allowedValues)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) || allowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,44 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||
}
|
||||
|
||||
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
|
||||
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
||||
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
"Dieser Kandidat hat noch keinen gepflegten Clip-Link. Bitte zuerst die Clip-Compilation am Kandidaten hinterlegen oder die Workflow-Regel umstellen.");
|
||||
}
|
||||
|
||||
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||
{
|
||||
var candidateIdentityKey = WorkflowRuleSettings.CandidateIdentityKey(candidate);
|
||||
var existingWinnerIdentities = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Candidate)
|
||||
.Where(item => item.SeasonId == seasonId && item.CategoryId != request.CategoryId)
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
})
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
var existingWinnerCount = existingWinnerIdentities.Count(item =>
|
||||
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
|
||||
||
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
|
||||
|
||||
if (existingWinnerCount >= winnerPlacementsRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
$"Diese Person hat bereits {existingWinnerCount} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||
}
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId);
|
||||
@@ -57,6 +95,13 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
existingResult.CategoryName = category.Name;
|
||||
}
|
||||
|
||||
var wasPublished = season?.WinnersPublishedAt is not null;
|
||||
if (wasPublished && season is not null)
|
||||
{
|
||||
season.WinnersPublishedAt = null;
|
||||
season.WinnersPublishedByTwitchId = null;
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"result.set",
|
||||
@@ -69,6 +114,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
categoryId = request.CategoryId,
|
||||
candidateId = request.CandidateId,
|
||||
candidateName = candidate.DisplayName,
|
||||
unpublishedWinners = wasPublished,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
@@ -93,12 +139,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
var result = await db.Results
|
||||
.Include(item => item.Category)
|
||||
.Include(item => item.Candidate)
|
||||
.Include(item => item.Season)
|
||||
.FirstOrDefaultAsync(item => item.Id == resultId);
|
||||
if (result is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var wasPublished = result.Season.WinnersPublishedAt is not null;
|
||||
if (wasPublished)
|
||||
{
|
||||
result.Season.WinnersPublishedAt = null;
|
||||
result.Season.WinnersPublishedByTwitchId = null;
|
||||
}
|
||||
|
||||
db.Results.Remove(result);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -112,10 +166,98 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
result.CategoryId,
|
||||
result.CandidateId,
|
||||
candidateName = result.Candidate.DisplayName,
|
||||
unpublishedWinners = wasPublished,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, resultId });
|
||||
}
|
||||
|
||||
private static async Task<IResult> PublishWinners(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var issues = await BuildWinnerPublicationIssuesAsync(db, seasonId, context.RequestAborted);
|
||||
if (issues.Length > 0)
|
||||
{
|
||||
return CreateWinnerPublicationError(issues);
|
||||
}
|
||||
|
||||
season.WinnersPublishedAt = DateTimeOffset.UtcNow;
|
||||
season.WinnersPublishedByTwitchId = session.TwitchUserId;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"winners.publish",
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Gewinner für {season.Year} wurden veröffentlicht.",
|
||||
new
|
||||
{
|
||||
seasonId,
|
||||
season.Year,
|
||||
season.WinnersPublishedAt,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
seasonId,
|
||||
winnersPublishedAt = season.WinnersPublishedAt,
|
||||
winnersPublishedByTwitchId = season.WinnersPublishedByTwitchId,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnpublishWinners(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var previousPublishedAt = season.WinnersPublishedAt;
|
||||
season.WinnersPublishedAt = null;
|
||||
season.WinnersPublishedByTwitchId = null;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"winners.unpublish",
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Gewinner für {season.Year} wurden von der Landingpage zurückgenommen.",
|
||||
new
|
||||
{
|
||||
seasonId,
|
||||
season.Year,
|
||||
previousPublishedAt,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved = true,
|
||||
seasonId,
|
||||
winnersPublishedAt = season.WinnersPublishedAt,
|
||||
winnersPublishedByTwitchId = season.WinnersPublishedByTwitchId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
|
||||
}
|
||||
|
||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
||||
|
||||
var wasCurrent = season.IsCurrent;
|
||||
var previousPhase = season.CurrentPhase;
|
||||
var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase);
|
||||
@@ -58,7 +56,6 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
season.Year = request.Year;
|
||||
season.Name = request.Name.Trim();
|
||||
season.ShowStreamUrl = showStreamUrl;
|
||||
season.CurrentPhase = request.CurrentPhase.Trim();
|
||||
season.IsCommunityOnly = request.IsCommunityOnly;
|
||||
season.NominationStartsAt = request.NominationStartsAt;
|
||||
@@ -98,7 +95,6 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
request.Year,
|
||||
request.Name,
|
||||
showStreamUrl,
|
||||
previousPhase,
|
||||
request.CurrentPhase,
|
||||
wasCurrent,
|
||||
|
||||
@@ -13,6 +13,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
{
|
||||
private const string FallbackMaintenanceTitle = "Sternenpause";
|
||||
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||
|
||||
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
@@ -32,6 +33,30 @@ public static class AdminSiteSettingsEndpoints
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOperationalSettings")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/optional-feature-settings", GetOptionalFeatureSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminOptionalFeatureSettings")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/optional-feature-settings", UpdateOptionalFeatureSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOptionalFeatureSettings")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/tracking-rules", GetTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules", UpdateTrackingRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/source", UpdateTrackingSource)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingSource")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminTrackingReviewNotes")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -47,6 +72,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName,
|
||||
settings.HostTagline,
|
||||
settings.NewsletterUrl,
|
||||
settings.ShareXUrl,
|
||||
settings.ShareDiscordUrl,
|
||||
settings.PrivacyEmail,
|
||||
settings.PrivacyPolicyContent,
|
||||
settings.PrivacyPolicyUpdatedBy,
|
||||
@@ -57,8 +84,27 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ContactContent,
|
||||
settings.SponsorsUrl,
|
||||
settings.SponsorsContent,
|
||||
settings.ShowactsUrl,
|
||||
settings.ShowactsContent,
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
|
||||
settings.StreamBannerLiveButtonUrl,
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
|
||||
settings.StreamBannerUseCompletedContent,
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
|
||||
settings.StreamBannerCompletedButtonUrl,
|
||||
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
|
||||
SeasonMappings.ReadSocialLinks(settings),
|
||||
SeasonMappings.ReadFaqItems(settings)));
|
||||
SeasonMappings.ReadFaqItems(settings),
|
||||
settings.ShowactFormSchemaJson ?? "[]"));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSiteSettings(
|
||||
@@ -84,6 +130,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||
settings.HostTagline = request.HostTagline.Trim();
|
||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
||||
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||
@@ -100,8 +148,27 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.ContactContent = request.ContactContent.Trim();
|
||||
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
||||
settings.SponsorsContent = request.SponsorsContent.Trim();
|
||||
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
|
||||
settings.ShowactsContent = request.ShowactsContent.Trim();
|
||||
settings.StreamBannerEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerEyebrow);
|
||||
settings.StreamBannerTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerTitle);
|
||||
settings.StreamBannerText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerText);
|
||||
settings.StreamBannerLiveButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLiveButtonLabel);
|
||||
settings.StreamBannerLiveButtonUrl = normalizedUrls.StreamBannerLiveButtonUrl;
|
||||
settings.StreamBannerLockedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLockedButtonLabel);
|
||||
settings.StreamBannerUseCompletedContent = request.StreamBannerUseCompletedContent;
|
||||
settings.StreamBannerCompletedEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedEyebrow);
|
||||
settings.StreamBannerCompletedTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedTitle);
|
||||
settings.StreamBannerCompletedText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedText);
|
||||
settings.StreamBannerCompletedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedButtonLabel);
|
||||
settings.StreamBannerCompletedButtonUrl = normalizedUrls.StreamBannerCompletedButtonUrl;
|
||||
settings.AwardsSectionTitle = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionTitle);
|
||||
settings.AwardsSectionDescription = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionDescription);
|
||||
settings.SubcategoriesSectionTitle = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionTitle);
|
||||
settings.SubcategoriesSectionDescription = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionDescription);
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]";
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -131,9 +198,14 @@ public static class AdminSiteSettingsEndpoints
|
||||
socialLinks = [];
|
||||
|
||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out 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))
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.StreamBannerLiveButtonUrl, "Finale-Banner Button-Link", out var streamBannerLiveButtonUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.StreamBannerCompletedButtonUrl, "Finale-Banner Abschluss-Link", out var streamBannerCompletedButtonUrl, out errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
@@ -141,9 +213,14 @@ public static class AdminSiteSettingsEndpoints
|
||||
normalizedUrls = new PublicSiteUrlSettings
|
||||
{
|
||||
NewsletterUrl = newsletterUrl,
|
||||
ShareXUrl = shareXUrl,
|
||||
ShareDiscordUrl = shareDiscordUrl,
|
||||
ImprintUrl = imprintUrl,
|
||||
ContactUrl = contactUrl,
|
||||
SponsorsUrl = sponsorsUrl,
|
||||
ShowactsUrl = showactsUrl,
|
||||
StreamBannerLiveButtonUrl = streamBannerLiveButtonUrl,
|
||||
StreamBannerCompletedButtonUrl = streamBannerCompletedButtonUrl,
|
||||
};
|
||||
|
||||
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
||||
@@ -184,9 +261,282 @@ public static class AdminSiteSettingsEndpoints
|
||||
private sealed class PublicSiteUrlSettings
|
||||
{
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ShareXUrl { get; set; } = string.Empty;
|
||||
public string ShareDiscordUrl { 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;
|
||||
public string ShowactsUrl { get; set; } = string.Empty;
|
||||
public string StreamBannerLiveButtonUrl { get; set; } = string.Empty;
|
||||
public string StreamBannerCompletedButtonUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOptionalFeatureSettings(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTrackingRules(AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ToTrackingRulesResponse(settings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingRules(
|
||||
HttpContext context,
|
||||
UpdateTrackingRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var applyResult = ApplyTrackingRulesRequest(settings, request);
|
||||
if (applyResult is not null)
|
||||
{
|
||||
return applyResult;
|
||||
}
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-rules.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Rules wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl,
|
||||
manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes,
|
||||
importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled),
|
||||
optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled),
|
||||
flagCount = after.Flags.Count(item => item.Enabled),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingSource(
|
||||
HttpContext context,
|
||||
UpdateTrackingSourceRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService,
|
||||
NominationTrackingReviewService trackingReviewService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
var updatedRules = rules with
|
||||
{
|
||||
Source = new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? rules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview),
|
||||
};
|
||||
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules);
|
||||
|
||||
await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted);
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-source.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Source wurde aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeBaseUrl = before.Source.BaseUrl,
|
||||
afterBaseUrl = after.Source.BaseUrl,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTrackingReviewNotes(
|
||||
HttpContext context,
|
||||
UpdateTrackingReviewNotesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var beforeNotes = settings.TrackingReviewNotes ?? string.Empty;
|
||||
var before = ToTrackingRulesResponse(settings);
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with
|
||||
{
|
||||
Source = rules.Source with
|
||||
{
|
||||
ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview,
|
||||
},
|
||||
});
|
||||
|
||||
var after = ToTrackingRulesResponse(settings);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"tracking-review-notes.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Tracking Review Notes wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
beforeLength = beforeNotes.Length,
|
||||
afterLength = settings.TrackingReviewNotes.Length,
|
||||
beforeShowInReview = before.Source.ShowManualReviewNotesInReview,
|
||||
afterShowInReview = after.Source.ShowManualReviewNotesInReview,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
||||
HttpContext context,
|
||||
UpdateOptionalFeatureSettingsRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToOptionalFeatureSettingsResponse(settings);
|
||||
var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt);
|
||||
if (scheduleValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = scheduleValidationError });
|
||||
}
|
||||
|
||||
var disabledMessage = NormalizeOptionalFeatureText(
|
||||
request.ClipSubmissionDisabledMessage,
|
||||
FallbackClipSubmissionDisabledMessage,
|
||||
240);
|
||||
var showactDisabledMessage = NormalizeOptionalFeatureText(
|
||||
request.ShowactApplicationDisabledMessage,
|
||||
"Showact-Bewerbungen sind aktuell geschlossen.",
|
||||
240);
|
||||
|
||||
settings.ClipSubmissionsEnabled = request.ClipSubmissionsEnabled;
|
||||
settings.ClipReviewEnabled = request.ClipReviewEnabled;
|
||||
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
||||
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
||||
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
||||
settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt;
|
||||
settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt;
|
||||
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
||||
settings.SponsorsVisible = request.SponsorsVisible;
|
||||
|
||||
var after = ToOptionalFeatureSettingsResponse(settings);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"optional-features.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Optionale Workflow-Features wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
before,
|
||||
after,
|
||||
changes = BuildOptionalFeatureChanges(before, after),
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(after);
|
||||
}
|
||||
|
||||
private static AdminOptionalFeatureSettingsResponse ToOptionalFeatureSettingsResponse(SiteSettings settings) =>
|
||||
new(
|
||||
settings.ClipSubmissionsEnabled,
|
||||
settings.ClipReviewEnabled,
|
||||
settings.ClipAdminMenuVisible,
|
||||
string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage)
|
||||
? FallbackClipSubmissionDisabledMessage
|
||||
: settings.ClipSubmissionDisabledMessage,
|
||||
settings.ShowactApplicationsEnabled,
|
||||
settings.ShowactApplicationStartsAt,
|
||||
settings.ShowactApplicationEndsAt,
|
||||
ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: settings.ShowactApplicationDisabledMessage,
|
||||
settings.SponsorsVisible);
|
||||
|
||||
private static string NormalizeOptionalFeatureText(string? value, string fallback, int maxLength)
|
||||
{
|
||||
var trimmed = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static object[] BuildOptionalFeatureChanges(
|
||||
AdminOptionalFeatureSettingsResponse before,
|
||||
AdminOptionalFeatureSettingsResponse after)
|
||||
{
|
||||
var changes = new List<object>();
|
||||
AddOperationalChange(changes, "clipSubmissionsEnabled", "Clip-Einreichung", before.ClipSubmissionsEnabled, after.ClipSubmissionsEnabled);
|
||||
AddOperationalChange(changes, "clipReviewEnabled", "Clip-Review", before.ClipReviewEnabled, after.ClipReviewEnabled);
|
||||
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
||||
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
||||
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
||||
AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt);
|
||||
AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt);
|
||||
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
||||
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
||||
return changes.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
|
||||
@@ -197,7 +547,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||
usesDatabaseDemo,
|
||||
@@ -212,6 +562,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
twitchSettings.ClientSecretSet,
|
||||
twitchSettings.RedirectUri,
|
||||
twitchSettings.Scope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||
@@ -248,6 +599,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
||||
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
||||
var twitchScope = request.TwitchScope.Trim();
|
||||
if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." });
|
||||
}
|
||||
|
||||
var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours);
|
||||
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||
|
||||
@@ -307,6 +664,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.TwitchClientSecret,
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope);
|
||||
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
|
||||
|
||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||
? newPassword
|
||||
@@ -339,11 +697,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
"operational-settings.update",
|
||||
"site-settings",
|
||||
settings.Id.ToString(),
|
||||
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
|
||||
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
|
||||
new
|
||||
{
|
||||
settings.DemoLoginEnabled,
|
||||
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
||||
settings.SessionIdleTimeoutHours,
|
||||
settings.MaintenanceModeEnabled,
|
||||
changes,
|
||||
},
|
||||
@@ -438,6 +797,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope,
|
||||
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||
@@ -459,6 +819,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
||||
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
||||
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
||||
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
|
||||
|
||||
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
||||
{
|
||||
@@ -536,6 +897,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
int SessionIdleTimeoutHours,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
@@ -546,4 +908,136 @@ public static class AdminSiteSettingsEndpoints
|
||||
string RedirectUri,
|
||||
string Scope,
|
||||
bool Configured);
|
||||
|
||||
private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request)
|
||||
{
|
||||
if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
var currentRules = TrackingRulesSettings.Read(settings);
|
||||
var configuration = new TrackingRulesConfiguration(
|
||||
new TrackingSourceSetting(
|
||||
TrackingRulesSettings.ProviderKey,
|
||||
normalizedBaseUrl,
|
||||
request.Source?.NotesSummary ?? currentRules.Source.NotesSummary,
|
||||
request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview),
|
||||
(request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(),
|
||||
(request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray());
|
||||
|
||||
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration);
|
||||
settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl;
|
||||
settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings)
|
||||
{
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
return new AdminTrackingRulesResponse(
|
||||
new AdminTrackingSourceDto(
|
||||
rules.Source.ProviderKey,
|
||||
"TwitchTracker Basic API",
|
||||
TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl),
|
||||
rules.Source.NotesSummary,
|
||||
rules.Source.ShowManualReviewNotesInReview),
|
||||
rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(),
|
||||
rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(),
|
||||
settings.TrackingReviewNotes ?? string.Empty);
|
||||
}
|
||||
|
||||
private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
rule.WindowKey,
|
||||
rule.AutoSupportedWindowKeys,
|
||||
rule.ProviderFieldKey,
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
rule.IgnoredCategories,
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.SourceSupport,
|
||||
rule.Description,
|
||||
rule.RequiredForAutoClassification,
|
||||
rule.ShowInReview,
|
||||
rule.ShowInAdminSummary,
|
||||
rule.ManualOverrideAllowed,
|
||||
TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d),
|
||||
(rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(),
|
||||
rule.TopCount,
|
||||
rule.MinPrimaryCategorySharePercent,
|
||||
rule.MinPrimaryCategoryHours,
|
||||
rule.MaxDistinctCategoriesBeforeFlag,
|
||||
(rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
rule.MatchAwardCategoryAgainstTopCategories,
|
||||
rule.FlagIfAwardCategoryNotInTopX,
|
||||
rule.FlagIfCategorySpreadTooWide,
|
||||
rule.FlagIfNoCategoryContextAvailable,
|
||||
rule.MinValue,
|
||||
rule.MaxValue);
|
||||
|
||||
private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) =>
|
||||
new(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Enabled,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.AutoTriggerEnabled,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage)
|
||||
{
|
||||
normalizedValue = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
var trimmed = (rawValue ?? string.Empty).Trim();
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,20 @@ public static class AdminTeamEndpoints
|
||||
|
||||
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
|
||||
[
|
||||
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "/admin/dashboard", true),
|
||||
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "/admin/years", false),
|
||||
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "/admin/nominations", false),
|
||||
new(AdminPermissionCatalog.Categories, "Kategorien", "Kategorien und Limits verwalten.", "/admin/categories", false),
|
||||
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis bearbeiten.", "/admin/candidates", false),
|
||||
new(AdminPermissionCatalog.Clips, "Clips", "Clip-Einreichungen prüfen.", "/admin/clips", false),
|
||||
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "/admin/risk", true),
|
||||
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "/admin/users-logs", true),
|
||||
new(AdminPermissionCatalog.Analytics, "Analytics", "Metriken und Rankings lesen.", "/admin/analytics", true),
|
||||
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "/admin/winners", false),
|
||||
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Datenschutz und öffentliche Inhalte pflegen.", "/admin/content", false),
|
||||
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang und Wartung sehen.", "/admin/settings", true),
|
||||
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "/admin/team", false),
|
||||
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "Betrieb", "/admin/dashboard", true),
|
||||
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "Betrieb", "/admin/nominations", false),
|
||||
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "Awards", "/admin/years", false),
|
||||
new(AdminPermissionCatalog.Categories, "Kategorien", "Hauptkategorien, Unterkategorien und Limits verwalten.", "Awards", "/admin/categories", false),
|
||||
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis, Clips und Annahmestatus pflegen.", "Awards", "/admin/candidates", false),
|
||||
new(AdminPermissionCatalog.Clips, "Clips", "Optionale Clip-Einreichungen prüfen.", "Awards", "/admin/clips", false),
|
||||
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Footer, Showacts und öffentliche Inhalte pflegen.", "Landingpage", "/admin/content", false),
|
||||
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "Kontrolle", "/admin/risk", true),
|
||||
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "Kontrolle", "/admin/users-logs", true),
|
||||
new(AdminPermissionCatalog.Analytics, "Analytics", "Jahresmetriken und Überblick lesen.", "Auswertung", "/admin/analytics", true),
|
||||
new(AdminPermissionCatalog.Voting, "Voting", "Stimmenlage und Gewinner-Vorbereitung sehen.", "Auswertung", "/admin/voting", true),
|
||||
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "Auswertung", "/admin/winners", false),
|
||||
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang, Wartung und Workflow-Steuerung sehen.", "Einstellungen", "/admin/settings", true),
|
||||
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "Einstellungen", "/admin/team", false),
|
||||
];
|
||||
|
||||
private static readonly AdminTeamRoleDto[] DefaultRoles =
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWorkflowRules(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(season, settings).Select(ToWorkflowRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWorkflowRules(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpdateWorkflowRulesRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var before = WorkflowRuleSettings.Read(season, settings);
|
||||
var mergedRules = WorkflowRuleSettings.Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
|
||||
return requestRule is null
|
||||
? defaultRule
|
||||
: new WorkflowRuleSetting(
|
||||
defaultRule.Key,
|
||||
defaultRule.Label,
|
||||
requestRule.Enabled,
|
||||
requestRule.Limit,
|
||||
requestRule.Mode,
|
||||
defaultRule.Description);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
|
||||
var after = WorkflowRuleSettings.Read(season, settings);
|
||||
var changes = after
|
||||
.Select(rule =>
|
||||
{
|
||||
var previous = before.First(item => item.Key == rule.Key);
|
||||
return new
|
||||
{
|
||||
field = rule.Key,
|
||||
label = rule.Label,
|
||||
from = $"{previous.Enabled}/{previous.Limit}/{previous.Mode}",
|
||||
to = $"{rule.Enabled}/{rule.Limit}/{rule.Mode}",
|
||||
sensitive = false,
|
||||
};
|
||||
})
|
||||
.Where(change => change.from != change.to)
|
||||
.ToArray();
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"workflow-rules.update",
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.",
|
||||
new { seasonId = season.Id, season.Year, changes },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new AdminWorkflowRulesResponse(after.Select(ToWorkflowRuleDto).ToArray()));
|
||||
}
|
||||
|
||||
private static AdminWorkflowRuleDto ToWorkflowRuleDto(WorkflowRuleSetting rule) =>
|
||||
new(rule.Key, rule.Label, rule.Enabled, rule.Limit, rule.Mode, rule.Description);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> DemoLogin(
|
||||
HttpContext context,
|
||||
IHostEnvironment environment,
|
||||
AwardsDbContext db,
|
||||
IConfiguration configuration,
|
||||
DemoLoginRequest request,
|
||||
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
|
||||
var password = request.Password ?? string.Empty;
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
var databaseDemoConfigured = settings is not null
|
||||
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
|
||||
&& settings.DemoLoginManagedByDatabase;
|
||||
|
||||
string twitchUserId;
|
||||
string displayName;
|
||||
bool credentialsMatch;
|
||||
var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (databaseDemoConfigured && settings is not null)
|
||||
{
|
||||
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
|
||||
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||
displayName = settings.DemoLoginDisplayName.Trim();
|
||||
|
||||
if (!credentialsMatch
|
||||
&& environment.IsDevelopment()
|
||||
&& IsDemoLoginEnabled(configuration)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
&& !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var configuredLogin = ReadDemoLoginIdentifier(configuration);
|
||||
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuredLogin)
|
||||
|| string.IsNullOrWhiteSpace(configuredPassword)
|
||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(displayName))
|
||||
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Demo login is not fully configured." },
|
||||
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
|
||||
|
||||
credentialsMatch = LoginMatchesIdentifier(
|
||||
login,
|
||||
configuredLogin,
|
||||
configuredEmail,
|
||||
twitchUserId,
|
||||
displayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
|
||||
twitchUserId = twitchUserId.Trim();
|
||||
displayName = displayName.Trim();
|
||||
fallbackConfiguredLogin,
|
||||
fallbackConfiguredEmail,
|
||||
fallbackConfiguredTwitchUserId,
|
||||
fallbackConfiguredDisplayName)
|
||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||
displayName = fallbackConfiguredDisplayName.Trim();
|
||||
}
|
||||
|
||||
if (!credentialsMatch)
|
||||
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
|
||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||
@@ -36,6 +36,7 @@ public static partial class AuthEndpoints
|
||||
|
||||
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
UserSession session,
|
||||
bool mustChangePassword = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
|
||||
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||
var sessionRole = teamMember?.Role ?? session.Role;
|
||||
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
|
||||
return new(
|
||||
session.SessionToken,
|
||||
session.TwitchUserId,
|
||||
teamMember?.DisplayName ?? session.DisplayName,
|
||||
AdminRoles.Normalize(sessionRole),
|
||||
permissionKeys,
|
||||
sessionIdleTimeoutHours,
|
||||
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||
teamMember?.Login,
|
||||
teamMember?.BoundTwitchUserId,
|
||||
|
||||
@@ -44,7 +44,7 @@ public static partial class AuthEndpoints
|
||||
context.RequestAborted);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(
|
||||
@@ -92,7 +92,7 @@ public static partial class AuthEndpoints
|
||||
session.Role = AdminRoles.Normalize(member.Role);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static string BuildTeamSessionId(string login) =>
|
||||
|
||||
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||
false,
|
||||
false,
|
||||
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
|
||||
currentSessionUsesBoundTwitch,
|
||||
currentSessionUsesBoundTwitch
|
||||
? null
|
||||
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
||||
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||
|
||||
@@ -28,6 +28,22 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
||||
}
|
||||
|
||||
var siteSettings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (siteSettings is null)
|
||||
{
|
||||
return Results.Problem("Site settings are missing.");
|
||||
}
|
||||
|
||||
if (!siteSettings.ClipSubmissionsEnabled)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||
: siteSettings.ClipSubmissionDisabledMessage;
|
||||
return Results.BadRequest(new { message });
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||
if (clipSeasonResolution.Result is not null)
|
||||
|
||||
@@ -22,6 +22,10 @@ public static partial class PublicEndpoints
|
||||
.WithName("GetWinnerArchive")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{year:int}/sponsors", GetSponsors)
|
||||
.WithName("GetPublicSponsors")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
||||
.WithName("GetUserParticipation")
|
||||
.WithOpenApi();
|
||||
@@ -41,6 +45,11 @@ public static partial class PublicEndpoints
|
||||
.WithName("CreateClip")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/showacts", CreateShowactApplication)
|
||||
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||
.WithName("CreateShowactApplication")
|
||||
.WithOpenApi();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class PublicEndpoints
|
||||
{
|
||||
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
|
||||
|
||||
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Year == year);
|
||||
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null || !settings.SponsorsVisible)
|
||||
{
|
||||
return Results.Ok(new PublicSponsorsResponse(year, []));
|
||||
}
|
||||
|
||||
var sponsors = await db.Sponsors
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == season.Id && item.IsVisible)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.Select(item => new SponsorDto(
|
||||
item.Id,
|
||||
item.SeasonId,
|
||||
item.Name,
|
||||
item.WebsiteUrl,
|
||||
item.LogoUrl,
|
||||
item.Description,
|
||||
item.Tier,
|
||||
item.SortOrder,
|
||||
item.IsVisible))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private static async Task<IResult> CreateShowactApplication(
|
||||
HttpContext context,
|
||||
CreateShowactApplicationRequest request,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: settings.ShowactApplicationDisabledMessage,
|
||||
});
|
||||
}
|
||||
|
||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted);
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
||||
var hasDynamicForm = schema.Count > 0;
|
||||
|
||||
if (hasDynamicForm)
|
||||
{
|
||||
Dictionary<string, string> responses;
|
||||
try
|
||||
{
|
||||
responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson)
|
||||
? new Dictionary<string, string>()
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(request.FieldResponsesJson, ShowactJsonOptions) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||
}
|
||||
|
||||
MergeLegacyShowactFieldsIntoResponses(schema, responses, request);
|
||||
|
||||
var dynamicValidationError = ValidateShowactResponses(schema, responses);
|
||||
if (dynamicValidationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = dynamicValidationError });
|
||||
}
|
||||
|
||||
var artistNameField = schema.FirstOrDefault(f => f.IsArtistName);
|
||||
var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt";
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions);
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName[..Math.Min(artistName.Length, 120)],
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = technicalNotes,
|
||||
ReferenceUrl = referenceUrl,
|
||||
FieldResponsesJson = fieldResponsesJson,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Legacy fixed-fields path
|
||||
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||
var description = NormalizePublicText(request.Description, 1000);
|
||||
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrValidEmail(contactEmail))
|
||||
{
|
||||
return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||
}
|
||||
|
||||
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
var metadata = RequestMetadataReader.Read(context);
|
||||
var application = new ShowactApplication
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
ArtistName = artistName,
|
||||
ContactEmail = contactEmail,
|
||||
ContactDiscord = contactDiscord,
|
||||
PlatformUrl = platformUrl,
|
||||
PerformanceType = performanceType,
|
||||
Description = description,
|
||||
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||
ReferenceUrl = referenceUrl,
|
||||
Status = "pending",
|
||||
CreatedFromIp = metadata.ClientIp,
|
||||
UserAgent = metadata.UserAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ShowactApplications.Add(application);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ShowactFieldDefinition
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||
[JsonPropertyName("required")] public bool Required { get; set; }
|
||||
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
|
||||
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
|
||||
}
|
||||
|
||||
private static string NormalizePublicText(string? value, int maxLength)
|
||||
{
|
||||
var trimmed = (value ?? string.Empty).Trim();
|
||||
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static bool IsBlankOrHttpUrl(string value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||
|
||||
private static bool IsBlankOrValidEmail(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
|
||||
|
||||
private static bool IsBlankOrValidJsonObject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(value);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Object;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateShowactResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses)
|
||||
{
|
||||
foreach (var field in schema)
|
||||
{
|
||||
var value = responses.TryGetValue(field.Id, out var rawValue)
|
||||
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
|
||||
: string.Empty;
|
||||
responses[field.Id] = value;
|
||||
|
||||
if (field.Required && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
|
||||
? $"Bitte bestaetige: {field.Label}"
|
||||
: $"{field.Label} ist erforderlich.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
|
||||
{
|
||||
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
|
||||
}
|
||||
|
||||
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
|
||||
{
|
||||
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
|
||||
{
|
||||
if (field.MaxLength > 0)
|
||||
{
|
||||
return Math.Min(field.MaxLength, 2000);
|
||||
}
|
||||
|
||||
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
|
||||
}
|
||||
|
||||
private static void MergeLegacyShowactFieldsIntoResponses(
|
||||
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||
IDictionary<string, string> responses,
|
||||
CreateShowactApplicationRequest request)
|
||||
{
|
||||
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
|
||||
MergeResponseValue(artistField, request.ArtistName, responses, 120);
|
||||
|
||||
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
|
||||
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
|
||||
|
||||
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
|
||||
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
|
||||
|
||||
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
|
||||
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
|
||||
|
||||
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
|
||||
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
|
||||
|
||||
var performanceField = schema.FirstOrDefault(field =>
|
||||
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|
||||
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
|
||||
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
|
||||
|
||||
var descriptionField = schema.FirstOrDefault(field =>
|
||||
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
|
||||
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|
||||
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
|
||||
MergeResponseValue(descriptionField, request.Description, responses, 1000);
|
||||
|
||||
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
|
||||
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
|
||||
}
|
||||
|
||||
private static void MergeResponseValue(
|
||||
ShowactFieldDefinition? field,
|
||||
string? requestValue,
|
||||
IDictionary<string, string> responses,
|
||||
int maxLength)
|
||||
{
|
||||
if (field is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = NormalizePublicText(requestValue, maxLength);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
responses[field.Id] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Id.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
|
||||
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
|
||||
{
|
||||
var haystack = field.Label.ToLowerInvariant();
|
||||
return needles.Any(haystack.Contains);
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,14 @@ public static partial class PublicEndpoints
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
IRiskRuleService riskRuleService,
|
||||
NominationEnrichmentService nominationEnrichmentService)
|
||||
{
|
||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
||||
|
||||
if (submittedNominations.Length is 0 or > 3)
|
||||
if (submittedNominations.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
|
||||
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
||||
@@ -35,7 +36,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var distinctStreamUrls = submittedNominations
|
||||
.Select(item => item.StreamUrl)
|
||||
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
@@ -54,16 +55,48 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
|
||||
}
|
||||
|
||||
var category = await db.Categories
|
||||
.Include(item => item.Season)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var linkBlacklist = NominationLinkBlacklistSettings.Read(settings);
|
||||
var blacklistedStreamUrl = submittedNominations
|
||||
.Select(item => item.StreamUrl)
|
||||
.FirstOrDefault(item => NominationLinkBlacklistSettings.IsBlocked(item, linkBlacklist));
|
||||
|
||||
if (category is null)
|
||||
if (blacklistedStreamUrl is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
|
||||
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." });
|
||||
}
|
||||
|
||||
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted);
|
||||
if (string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var groupCategories = await db.Categories
|
||||
.Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToArrayAsync(context.RequestAborted);
|
||||
|
||||
if (groupCategories.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
||||
}
|
||||
|
||||
var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories);
|
||||
if (submittedNominations.Length > maxNomineesPerUser)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||
if (nominationSeasonResolution.Result is not null)
|
||||
{
|
||||
return nominationSeasonResolution.Result;
|
||||
@@ -78,15 +111,16 @@ public static partial class PublicEndpoints
|
||||
var submitterId = submitterIdResult.SubmitterId!;
|
||||
var requestMetadata = RequestMetadataReader.Read(context);
|
||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
item.SeasonId == season.Id
|
||||
&& item.CategoryGroupName == categoryGroupName
|
||||
&& item.SubmittedByTwitchId == submitterId
|
||||
&& item.Status == "pending");
|
||||
|
||||
var records = submittedNominations.Select(nomination => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
SeasonId = season.Id,
|
||||
CategoryId = null,
|
||||
CategoryGroupName = categoryGroupName,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||
@@ -95,6 +129,11 @@ public static partial class PublicEndpoints
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}).ToArray();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||
@@ -115,21 +154,21 @@ public static partial class PublicEndpoints
|
||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
resubmittedNominationRule.Severity,
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
||||
requestMetadata,
|
||||
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
season.Id,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
@@ -141,7 +180,7 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
||||
return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 });
|
||||
}
|
||||
|
||||
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||
@@ -180,4 +219,44 @@ public static partial class PublicEndpoints
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeNominationUrlForCompare(string value) =>
|
||||
value.Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
|
||||
{
|
||||
var configuredLimit = groupCategories
|
||||
.Select(item => item.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return Math.Clamp(configuredLimit, 1, 10);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveCategoryGroupNameAsync(
|
||||
AwardsDbContext db,
|
||||
int seasonId,
|
||||
CreateNominationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var categoryGroupName = request.CategoryGroupName?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(categoryGroupName))
|
||||
{
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!request.CategoryId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await db.Categories
|
||||
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
|
||||
.Select(item => item.GroupName)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,38 +26,60 @@ public static partial class PublicEndpoints
|
||||
{
|
||||
return Results.Problem("Site settings are missing.");
|
||||
}
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
|
||||
|
||||
var latestPublishedWinnerYear = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(result => result.Season.WinnersPublishedAt != null)
|
||||
.Select(result => (int?)result.Season.Year)
|
||||
.MaxAsync();
|
||||
|
||||
var winnerPreviewRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Season)
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result => result.Season.WinnersPublishedAt != null
|
||||
&& latestPublishedWinnerYear != null
|
||||
&& result.Season.Year == latestPublishedWinnerYear.Value)
|
||||
.OrderByDescending(result => result.Season.Year)
|
||||
.ThenBy(result => result.CategoryName)
|
||||
.Take(8)
|
||||
.Select(result => new
|
||||
{
|
||||
Year = result.Season.Year,
|
||||
CategoryGroup = result.Category.GroupName,
|
||||
result.CategoryName,
|
||||
WinnerName = result.Candidate.DisplayName,
|
||||
WinnerSlug = result.Candidate.ChannelSlug,
|
||||
WinnerPlatform = result.Candidate.Platform,
|
||||
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
var winnerPreviewItems = winnerPreviewRows
|
||||
.Select(result => new WinnerPreviewDto(
|
||||
result.Year,
|
||||
result.CategoryGroup,
|
||||
result.CategoryName,
|
||||
result.WinnerName,
|
||||
result.WinnerSlug,
|
||||
result.WinnerPlatform,
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||
result.ClipUrl,
|
||||
result.ClipTitle,
|
||||
result.ClipPlatform,
|
||||
result.ClipEmbedStatus))
|
||||
.ToArray();
|
||||
|
||||
var archiveYearRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.Where(result => result.Season.WinnersPublishedAt != null
|
||||
&& latestPublishedWinnerYear != null
|
||||
&& result.Season.Year < latestPublishedWinnerYear.Value)
|
||||
.GroupBy(result => result.Season.Year)
|
||||
.Select(group => new
|
||||
{
|
||||
@@ -75,13 +97,43 @@ public static partial class PublicEndpoints
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var featuredCategories = publicCategories
|
||||
.GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group
|
||||
.OrderBy(category => category.SortOrder)
|
||||
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var first = ordered[0];
|
||||
var groupDescription = ordered
|
||||
.Select(category => category.Description?.Trim())
|
||||
.FirstOrDefault(description => !string.IsNullOrWhiteSpace(description))
|
||||
?? string.Empty;
|
||||
var maxNomineesPerUser = ordered
|
||||
.Select(category => category.MaxNomineesPerUser)
|
||||
.Where(value => value > 0)
|
||||
.DefaultIfEmpty(3)
|
||||
.Max();
|
||||
|
||||
return new FeaturedCategoryDto(
|
||||
first.Id,
|
||||
first.GroupName,
|
||||
first.GroupName,
|
||||
groupDescription,
|
||||
maxNomineesPerUser);
|
||||
})
|
||||
.OrderBy(category => publicCategories
|
||||
.Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase))
|
||||
.Min(item => item.SortOrder))
|
||||
.ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var response = new OverviewResponse(
|
||||
season.Id,
|
||||
season.Year,
|
||||
season.Name,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
SeasonMappings.NormalizeSeasonStreamUrl(season.ShowStreamUrl),
|
||||
season.CurrentPhase,
|
||||
season.IsCommunityOnly,
|
||||
"Twitch",
|
||||
@@ -92,24 +144,50 @@ public static partial class PublicEndpoints
|
||||
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
||||
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||
},
|
||||
publicCategories
|
||||
.Select(category => new FeaturedCategoryDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
category.Name,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser))
|
||||
.ToArray(),
|
||||
featuredCategories,
|
||||
winnerPreviewItems,
|
||||
archiveYears,
|
||||
new PublicSiteContentDto(
|
||||
siteSettings.HostDisplayName,
|
||||
siteSettings.HostTagline,
|
||||
siteSettings.NewsletterUrl,
|
||||
siteSettings.ShareXUrl,
|
||||
siteSettings.ShareDiscordUrl,
|
||||
siteSettings.PrivacyEmail,
|
||||
siteSettings.PrivacyPolicyContent,
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.AwardsSectionTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.AwardsSectionDescription),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.SubcategoriesSectionTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.SubcategoriesSectionDescription),
|
||||
new PublicStreamBannerContentDto(
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerEyebrow),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerText),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerLiveButtonLabel),
|
||||
SeasonMappings.NormalizeSeasonStreamUrl(siteSettings.StreamBannerLiveButtonUrl),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerLockedButtonLabel),
|
||||
siteSettings.StreamBannerUseCompletedContent,
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedEyebrow),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedTitle),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedText),
|
||||
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedButtonLabel),
|
||||
SeasonMappings.NormalizeSeasonStreamUrl(siteSettings.StreamBannerCompletedButtonUrl)),
|
||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||
SeasonMappings.BuildFooterLinks(siteSettings)),
|
||||
new PublicFeatureFlagsDto(
|
||||
siteSettings.ClipSubmissionsEnabled,
|
||||
siteSettings.ClipReviewEnabled,
|
||||
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||
: siteSettings.ClipSubmissionDisabledMessage,
|
||||
showactApplicationsOpenNow,
|
||||
siteSettings.ShowactApplicationStartsAt,
|
||||
siteSettings.ShowactApplicationEndsAt,
|
||||
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
||||
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||
: siteSettings.ShowactApplicationDisabledMessage,
|
||||
siteSettings.SponsorsVisible,
|
||||
siteSettings.ShowactFormSchemaJson ?? "[]"),
|
||||
SeasonMappings.ReadFaqItems(siteSettings));
|
||||
|
||||
return Results.Ok(response);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Common;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
|
||||
}
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
.ToArray();
|
||||
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||
@@ -54,17 +57,36 @@ public static partial class PublicEndpoints
|
||||
category.GroupName,
|
||||
category.Description,
|
||||
category.MaxNomineesPerUser,
|
||||
category.Candidates.Select(candidate =>
|
||||
category.Candidates
|
||||
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(candidate =>
|
||||
{
|
||||
var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey);
|
||||
var candidateClipUrl = string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)
|
||||
|| string.Equals(candidate.ClipEmbedStatus, "blocked", StringComparison.OrdinalIgnoreCase)
|
||||
? null
|
||||
: candidate.ClipCompilationUrl.Trim();
|
||||
var clipUrl = candidateClipUrl ?? clip?.ClipUrl;
|
||||
var clipTitle = candidateClipUrl is not null
|
||||
? string.IsNullOrWhiteSpace(candidate.ClipCompilationTitle) ? "Highlight-Clip ansehen" : candidate.ClipCompilationTitle.Trim()
|
||||
: clip?.Title;
|
||||
var clipPlatform = candidateClipUrl is not null
|
||||
? string.IsNullOrWhiteSpace(candidate.ClipCompilationPlatform) ? candidate.Platform : candidate.ClipCompilationPlatform.Trim()
|
||||
: clip?.Platform;
|
||||
var clipEmbedStatus = candidateClipUrl is not null
|
||||
? candidate.ClipEmbedStatus
|
||||
: null;
|
||||
|
||||
return new CandidateSummaryDto(
|
||||
candidate.Id,
|
||||
candidate.DisplayName,
|
||||
candidate.ChannelSlug,
|
||||
SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug),
|
||||
candidate.Platform,
|
||||
clip?.ClipUrl,
|
||||
clip?.Title,
|
||||
clip?.Platform);
|
||||
clipUrl,
|
||||
clipTitle,
|
||||
clipPlatform,
|
||||
clipEmbedStatus);
|
||||
}).ToArray()))
|
||||
.ToArray()));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
|
||||
|
||||
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||
{
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||
if (!usesDatabaseDemo)
|
||||
{
|
||||
return IsDemoLoginEnabled(configuration);
|
||||
|
||||
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
|
||||
.Select(item => new
|
||||
{
|
||||
item.CategoryId,
|
||||
item.CategoryGroupName,
|
||||
item.Status,
|
||||
Nominee = item.CandidateId != null
|
||||
? item.Candidate!.DisplayName
|
||||
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
|
||||
var groupedNominations = nominations
|
||||
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
|
||||
.Select(group => new UserNominationStateDto(
|
||||
group.Key,
|
||||
group.Key.CategoryId,
|
||||
group.Key.CategoryGroupName,
|
||||
group.Select(item => item.Nominee!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()))
|
||||
|
||||
@@ -12,14 +12,24 @@ public static partial class PublicEndpoints
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Year == year)
|
||||
.Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase })
|
||||
.Select(item => new { item.Id, item.Year, item.WinnersPublishedAt })
|
||||
.FirstOrDefaultAsync();
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
|
||||
if (season.WinnersPublishedAt is null)
|
||||
{
|
||||
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||
}
|
||||
|
||||
var latestPublishedWinnerYear = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(result => result.Season.WinnersPublishedAt != null)
|
||||
.Select(result => (int?)result.Season.Year)
|
||||
.MaxAsync();
|
||||
if (latestPublishedWinnerYear == season.Year)
|
||||
{
|
||||
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||
}
|
||||
@@ -31,28 +41,32 @@ public static partial class PublicEndpoints
|
||||
.OrderBy(result => result.CategoryName)
|
||||
.Select(result => new
|
||||
{
|
||||
CategoryGroup = result.Category.GroupName,
|
||||
result.CategoryName,
|
||||
WinnerName = result.Candidate.DisplayName,
|
||||
WinnerSlug = result.Candidate.ChannelSlug,
|
||||
WinnerPlatform = result.Candidate.Platform,
|
||||
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
var items = winnerRows
|
||||
.Select(result => new WinnerArchiveItemDto(
|
||||
result.CategoryGroup,
|
||||
result.CategoryName,
|
||||
result.WinnerName,
|
||||
result.WinnerSlug,
|
||||
result.WinnerPlatform,
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||
result.ClipUrl,
|
||||
result.ClipTitle,
|
||||
result.ClipPlatform,
|
||||
result.ClipEmbedStatus))
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new WinnerArchiveResponse(year, items));
|
||||
}
|
||||
|
||||
private static bool CanExposeCurrentSeasonWinners(string currentPhase)
|
||||
{
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
return phaseKey is "completed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("TwitchTracker", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(4);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
|
||||
});
|
||||
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
||||
|
||||
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
||||
@@ -88,6 +93,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
||||
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
||||
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
||||
services.AddScoped<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
|
||||
services.AddScoped<NominationTrackingReviewService>();
|
||||
services.AddScoped<NominationEnrichmentService>();
|
||||
services.AddScoped<AdminSessionFilter>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -47,17 +47,11 @@ public static class WebApplicationExtensions
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await SessionBootstrapper.EnsureAsync(db);
|
||||
await OperationalTablesBootstrapper.EnsureAsync(db);
|
||||
await TeamAccountBootstrapper.EnsureAsync(db, app.Configuration);
|
||||
if (ShouldSeedPresentationData(app))
|
||||
{
|
||||
await SeedDataBootstrapper.EnsureAsync(db);
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Database initialization failed. Check the PostgreSQL connection, migrations, and seed data.");
|
||||
logger.LogError(error, "Database initialization failed. Check the PostgreSQL connection, migrations, and startup configuration.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -69,17 +63,4 @@ public static class WebApplicationExtensions
|
||||
app.MapPublicEndpoints();
|
||||
app.MapAdminEndpoints();
|
||||
}
|
||||
|
||||
private static bool ShouldSeedPresentationData(WebApplication app)
|
||||
{
|
||||
var mode = app.Configuration["VTSA_SEED_MODE"]
|
||||
?? app.Configuration["SeedData:Mode"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
return app.Environment.IsDevelopment();
|
||||
}
|
||||
|
||||
return mode.Trim().ToLowerInvariant() is "demo" or "presentation" or "sample";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,833 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AwardsDbContext))]
|
||||
[Migration("20260617060000_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CandidateId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CategoryName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<int>("SeasonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CandidateId");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
|
||||
b.ToTable("Results");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
CandidateId = 8,
|
||||
CategoryName = "VTuber des Jahres",
|
||||
SeasonId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
CandidateId = 9,
|
||||
CategoryName = "Bestes Live Event",
|
||||
SeasonId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
CandidateId = 10,
|
||||
CategoryName = "Clip des Jahres",
|
||||
SeasonId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
CandidateId = 11,
|
||||
CategoryName = "VTuber des Jahres",
|
||||
SeasonId = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5,
|
||||
CandidateId = 12,
|
||||
CategoryName = "Clip des Jahres",
|
||||
SeasonId = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 6,
|
||||
CandidateId = 13,
|
||||
CategoryName = "VTuber des Jahres",
|
||||
SeasonId = 4
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Candidate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ChannelSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)");
|
||||
|
||||
b.Property<int>("SeasonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
|
||||
b.ToTable("Candidates");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
CategoryId = 1,
|
||||
ChannelSlug = "@hoshimimiyu",
|
||||
DisplayName = "Hoshimi Miyu",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
CategoryId = 1,
|
||||
ChannelSlug = "@kurainu",
|
||||
DisplayName = "Kurainu",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
CategoryId = 1,
|
||||
ChannelSlug = "@shiroch",
|
||||
DisplayName = "Shiro Ch.",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
CategoryId = 2,
|
||||
ChannelSlug = "@kurainu",
|
||||
DisplayName = "Kurainu 3D Live",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5,
|
||||
CategoryId = 2,
|
||||
ChannelSlug = "@aoisakura",
|
||||
DisplayName = "Aoi Sakura Showcase",
|
||||
Platform = "YouTube",
|
||||
SeasonId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 6,
|
||||
CategoryId = 3,
|
||||
ChannelSlug = "@pyonkichikingdom",
|
||||
DisplayName = "Pyonkichi Kingdom",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 7,
|
||||
CategoryId = 4,
|
||||
ChannelSlug = "@moonrelay",
|
||||
DisplayName = "Moonrelay",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 8,
|
||||
CategoryId = 5,
|
||||
ChannelSlug = "@hoshimimiyu",
|
||||
DisplayName = "Hoshimi Miyu",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 9,
|
||||
CategoryId = 6,
|
||||
ChannelSlug = "@kurainu",
|
||||
DisplayName = "Kurainu 3D Live",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 10,
|
||||
CategoryId = 7,
|
||||
ChannelSlug = "@pyonkichikingdom",
|
||||
DisplayName = "Pyonkichi Kingdom",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 11,
|
||||
CategoryId = 8,
|
||||
ChannelSlug = "@aoisakura",
|
||||
DisplayName = "Aoi Sakura",
|
||||
Platform = "YouTube",
|
||||
SeasonId = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 12,
|
||||
CategoryId = 9,
|
||||
ChannelSlug = "@starbyte",
|
||||
DisplayName = "Starbyte",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 13,
|
||||
CategoryId = 10,
|
||||
ChannelSlug = "@tenshivox",
|
||||
DisplayName = "Tenshi Vox",
|
||||
Platform = "Twitch",
|
||||
SeasonId = 4
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Category", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("character varying(400)");
|
||||
|
||||
b.Property<string>("GroupName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)");
|
||||
|
||||
b.Property<int>("MaxNomineesPerUser")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<int>("SeasonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeasonId", "Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Description = "Die groesste Auszeichnung des Jahres.",
|
||||
GroupName = "Main Awards",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "VTuber des Jahres",
|
||||
SeasonId = 1,
|
||||
Slug = "vtuber-des-jahres",
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
Description = "Events, Konzerte und 3D-Shows.",
|
||||
GroupName = "Performance",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "Bestes Live Event",
|
||||
SeasonId = 1,
|
||||
Slug = "bestes-live-event",
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
Description = "Der lustigste oder emotionalste Clip des Jahres.",
|
||||
GroupName = "Clips & Highlights",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "Clip des Jahres",
|
||||
SeasonId = 1,
|
||||
Slug = "clip-des-jahres",
|
||||
SortOrder = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
Description = "Die aktivste und freundlichste Community.",
|
||||
GroupName = "Main Awards",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "Beste Community",
|
||||
SeasonId = 1,
|
||||
Slug = "beste-community",
|
||||
SortOrder = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5,
|
||||
Description = "Archivkategorie 2025.",
|
||||
GroupName = "Main Awards",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "VTuber des Jahres",
|
||||
SeasonId = 2,
|
||||
Slug = "vtuber-des-jahres",
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 6,
|
||||
Description = "Archivkategorie 2025.",
|
||||
GroupName = "Performance",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "Bestes Live Event",
|
||||
SeasonId = 2,
|
||||
Slug = "bestes-live-event",
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 7,
|
||||
Description = "Archivkategorie 2025.",
|
||||
GroupName = "Clips & Highlights",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "Clip des Jahres",
|
||||
SeasonId = 2,
|
||||
Slug = "clip-des-jahres",
|
||||
SortOrder = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 8,
|
||||
Description = "Archivkategorie 2024.",
|
||||
GroupName = "Main Awards",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "VTuber des Jahres",
|
||||
SeasonId = 3,
|
||||
Slug = "vtuber-des-jahres",
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 9,
|
||||
Description = "Archivkategorie 2024.",
|
||||
GroupName = "Clips & Highlights",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "Clip des Jahres",
|
||||
SeasonId = 3,
|
||||
Slug = "clip-des-jahres",
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 10,
|
||||
Description = "Archivkategorie 2023.",
|
||||
GroupName = "Main Awards",
|
||||
MaxNomineesPerUser = 3,
|
||||
Name = "VTuber des Jahres",
|
||||
SeasonId = 4,
|
||||
Slug = "vtuber-des-jahres",
|
||||
SortOrder = 1
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Nomination", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("CandidateId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CandidateText")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("SeasonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SubmittedByTwitchId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CandidateId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
|
||||
b.ToTable("Nominations");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
CandidateText = "Hoshimi Miyu",
|
||||
CategoryId = 1,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
SeasonId = 1,
|
||||
SubmittedByTwitchId = "twitch_hoshi"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
CandidateText = "Kurainu 3D Live",
|
||||
CategoryId = 2,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
SeasonId = 1,
|
||||
SubmittedByTwitchId = "twitch_kurainu"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Season", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CurrentPhase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("IsCommunityOnly")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsCurrent")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)");
|
||||
|
||||
b.Property<DateOnly>("NominationEndsAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly>("NominationStartsAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly>("ReviewEndsAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly>("ReviewStartsAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly>("ShowDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly>("VotingEndsAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly>("VotingStartsAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("Year")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Year")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Seasons");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
CurrentPhase = "Community Voting",
|
||||
IsCommunityOnly = true,
|
||||
IsCurrent = true,
|
||||
Name = "VTuber Star Awards 2026",
|
||||
NominationEndsAt = new DateOnly(2026, 5, 31),
|
||||
NominationStartsAt = new DateOnly(2026, 5, 1),
|
||||
ReviewEndsAt = new DateOnly(2026, 7, 10),
|
||||
ReviewStartsAt = new DateOnly(2026, 7, 1),
|
||||
ShowDate = new DateOnly(2026, 7, 20),
|
||||
VotingEndsAt = new DateOnly(2026, 6, 30),
|
||||
VotingStartsAt = new DateOnly(2026, 6, 1),
|
||||
Year = 2026
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
CurrentPhase = "Archived",
|
||||
IsCommunityOnly = true,
|
||||
IsCurrent = false,
|
||||
Name = "VTuber Star Awards 2025",
|
||||
NominationEndsAt = new DateOnly(2025, 5, 31),
|
||||
NominationStartsAt = new DateOnly(2025, 5, 1),
|
||||
ReviewEndsAt = new DateOnly(2025, 7, 10),
|
||||
ReviewStartsAt = new DateOnly(2025, 7, 1),
|
||||
ShowDate = new DateOnly(2025, 7, 20),
|
||||
VotingEndsAt = new DateOnly(2025, 6, 30),
|
||||
VotingStartsAt = new DateOnly(2025, 6, 1),
|
||||
Year = 2025
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
CurrentPhase = "Archived",
|
||||
IsCommunityOnly = true,
|
||||
IsCurrent = false,
|
||||
Name = "VTuber Star Awards 2024",
|
||||
NominationEndsAt = new DateOnly(2024, 5, 31),
|
||||
NominationStartsAt = new DateOnly(2024, 5, 1),
|
||||
ReviewEndsAt = new DateOnly(2024, 7, 10),
|
||||
ReviewStartsAt = new DateOnly(2024, 7, 1),
|
||||
ShowDate = new DateOnly(2024, 7, 20),
|
||||
VotingEndsAt = new DateOnly(2024, 6, 30),
|
||||
VotingStartsAt = new DateOnly(2024, 6, 1),
|
||||
Year = 2024
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
CurrentPhase = "Archived",
|
||||
IsCommunityOnly = true,
|
||||
IsCurrent = false,
|
||||
Name = "VTuber Star Awards 2023",
|
||||
NominationEndsAt = new DateOnly(2023, 5, 31),
|
||||
NominationStartsAt = new DateOnly(2023, 5, 1),
|
||||
ReviewEndsAt = new DateOnly(2023, 7, 10),
|
||||
ReviewStartsAt = new DateOnly(2023, 7, 1),
|
||||
ShowDate = new DateOnly(2023, 7, 20),
|
||||
VotingEndsAt = new DateOnly(2023, 6, 30),
|
||||
VotingStartsAt = new DateOnly(2023, 6, 1),
|
||||
Year = 2023
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("SeasonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTimeOffset>("SubmittedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SubmittedByTwitchId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
|
||||
b.ToTable("VoteBallots");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
SeasonId = 1,
|
||||
Status = "submitted",
|
||||
SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
SubmittedByTwitchId = "twitch_vote_1"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
SeasonId = 1,
|
||||
Status = "submitted",
|
||||
SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
SubmittedByTwitchId = "twitch_vote_2"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.VoteEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("BallotId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CandidateId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BallotId");
|
||||
|
||||
b.HasIndex("CandidateId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("VoteEntries");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
BallotId = 1,
|
||||
CandidateId = 1,
|
||||
CategoryId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
BallotId = 1,
|
||||
CandidateId = 4,
|
||||
CategoryId = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
BallotId = 2,
|
||||
CandidateId = 2,
|
||||
CategoryId = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
BallotId = 2,
|
||||
CandidateId = 6,
|
||||
CategoryId = 3
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
|
||||
{
|
||||
b.HasOne("Backend.Domain.Candidate", "Candidate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CandidateId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Backend.Domain.Season", "Season")
|
||||
.WithMany("Results")
|
||||
.HasForeignKey("SeasonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Candidate");
|
||||
|
||||
b.Navigation("Season");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Candidate", b =>
|
||||
{
|
||||
b.HasOne("Backend.Domain.Category", "Category")
|
||||
.WithMany("Candidates")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Backend.Domain.Season", "Season")
|
||||
.WithMany()
|
||||
.HasForeignKey("SeasonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Season");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Category", b =>
|
||||
{
|
||||
b.HasOne("Backend.Domain.Season", "Season")
|
||||
.WithMany("Categories")
|
||||
.HasForeignKey("SeasonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Season");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Nomination", b =>
|
||||
{
|
||||
b.HasOne("Backend.Domain.Candidate", "Candidate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CandidateId");
|
||||
|
||||
b.HasOne("Backend.Domain.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Backend.Domain.Season", "Season")
|
||||
.WithMany()
|
||||
.HasForeignKey("SeasonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Candidate");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Season");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
|
||||
{
|
||||
b.HasOne("Backend.Domain.Season", "Season")
|
||||
.WithMany()
|
||||
.HasForeignKey("SeasonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Season");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.VoteEntry", b =>
|
||||
{
|
||||
b.HasOne("Backend.Domain.VoteBallot", "Ballot")
|
||||
.WithMany("Entries")
|
||||
.HasForeignKey("BallotId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Backend.Domain.Candidate", "Candidate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CandidateId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Backend.Domain.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Ballot");
|
||||
|
||||
b.Navigation("Candidate");
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Category", b =>
|
||||
{
|
||||
b.Navigation("Candidates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.Season", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
|
||||
b.Navigation("Results");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Seasons",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Year = table.Column<int>(type: "integer", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
IsCurrent = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsCommunityOnly = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CurrentPhase = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||
NominationStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
NominationEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
VotingStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
VotingEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ReviewStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ReviewEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ShowDate = table.Column<DateOnly>(type: "date", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Seasons", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Categories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
GroupName = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Slug = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
MaxNomineesPerUser = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Categories", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Categories_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VoteBallots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubmittedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
SubmittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VoteBallots", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteBallots_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Candidates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ChannelSlug = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Platform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Candidates", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Candidates_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Candidates_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Nominations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubmittedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
CandidateId = table.Column<int>(type: "integer", nullable: true),
|
||||
CandidateText = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Nominations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_Candidates_CandidateId",
|
||||
column: x => x.CandidateId,
|
||||
principalTable: "Candidates",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Results",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
CandidateId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Results", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Results_Candidates_CandidateId",
|
||||
column: x => x.CandidateId,
|
||||
principalTable: "Candidates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Results_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VoteEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BallotId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
CandidateId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VoteEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteEntries_Candidates_CandidateId",
|
||||
column: x => x.CandidateId,
|
||||
principalTable: "Candidates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteEntries_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteEntries_VoteBallots_BallotId",
|
||||
column: x => x.BallotId,
|
||||
principalTable: "VoteBallots",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Seasons",
|
||||
columns: new[] { "Id", "CurrentPhase", "IsCommunityOnly", "IsCurrent", "Name", "NominationEndsAt", "NominationStartsAt", "ReviewEndsAt", "ReviewStartsAt", "ShowDate", "VotingEndsAt", "VotingStartsAt", "Year" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "Community Voting", true, true, "VTuber Star Awards 2026", new DateOnly(2026, 5, 31), new DateOnly(2026, 5, 1), new DateOnly(2026, 7, 10), new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 20), new DateOnly(2026, 6, 30), new DateOnly(2026, 6, 1), 2026 },
|
||||
{ 2, "Archived", true, false, "VTuber Star Awards 2025", new DateOnly(2025, 5, 31), new DateOnly(2025, 5, 1), new DateOnly(2025, 7, 10), new DateOnly(2025, 7, 1), new DateOnly(2025, 7, 20), new DateOnly(2025, 6, 30), new DateOnly(2025, 6, 1), 2025 },
|
||||
{ 3, "Archived", true, false, "VTuber Star Awards 2024", new DateOnly(2024, 5, 31), new DateOnly(2024, 5, 1), new DateOnly(2024, 7, 10), new DateOnly(2024, 7, 1), new DateOnly(2024, 7, 20), new DateOnly(2024, 6, 30), new DateOnly(2024, 6, 1), 2024 },
|
||||
{ 4, "Archived", true, false, "VTuber Star Awards 2023", new DateOnly(2023, 5, 31), new DateOnly(2023, 5, 1), new DateOnly(2023, 7, 10), new DateOnly(2023, 7, 1), new DateOnly(2023, 7, 20), new DateOnly(2023, 6, 30), new DateOnly(2023, 6, 1), 2023 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Categories",
|
||||
columns: new[] { "Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "Die groesste Auszeichnung des Jahres.", "Main Awards", 3, "VTuber des Jahres", 1, "vtuber-des-jahres", 1 },
|
||||
{ 2, "Events, Konzerte und 3D-Shows.", "Performance", 3, "Bestes Live Event", 1, "bestes-live-event", 2 },
|
||||
{ 3, "Der lustigste oder emotionalste Clip des Jahres.", "Clips & Highlights", 3, "Clip des Jahres", 1, "clip-des-jahres", 3 },
|
||||
{ 4, "Die aktivste und freundlichste Community.", "Main Awards", 3, "Beste Community", 1, "beste-community", 4 },
|
||||
{ 5, "Archivkategorie 2025.", "Main Awards", 3, "VTuber des Jahres", 2, "vtuber-des-jahres", 1 },
|
||||
{ 6, "Archivkategorie 2025.", "Performance", 3, "Bestes Live Event", 2, "bestes-live-event", 2 },
|
||||
{ 7, "Archivkategorie 2025.", "Clips & Highlights", 3, "Clip des Jahres", 2, "clip-des-jahres", 3 },
|
||||
{ 8, "Archivkategorie 2024.", "Main Awards", 3, "VTuber des Jahres", 3, "vtuber-des-jahres", 1 },
|
||||
{ 9, "Archivkategorie 2024.", "Clips & Highlights", 3, "Clip des Jahres", 3, "clip-des-jahres", 2 },
|
||||
{ 10, "Archivkategorie 2023.", "Main Awards", 3, "VTuber des Jahres", 4, "vtuber-des-jahres", 1 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "VoteBallots",
|
||||
columns: new[] { "Id", "SeasonId", "Status", "SubmittedAt", "SubmittedByTwitchId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, 1, "submitted", new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "twitch_vote_1" },
|
||||
{ 2, 1, "submitted", new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "twitch_vote_2" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Candidates",
|
||||
columns: new[] { "Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, 1, "@hoshimimiyu", "Hoshimi Miyu", "Twitch", 1 },
|
||||
{ 2, 1, "@kurainu", "Kurainu", "Twitch", 1 },
|
||||
{ 3, 1, "@shiroch", "Shiro Ch.", "Twitch", 1 },
|
||||
{ 4, 2, "@kurainu", "Kurainu 3D Live", "Twitch", 1 },
|
||||
{ 5, 2, "@aoisakura", "Aoi Sakura Showcase", "YouTube", 1 },
|
||||
{ 6, 3, "@pyonkichikingdom", "Pyonkichi Kingdom", "Twitch", 1 },
|
||||
{ 7, 4, "@moonrelay", "Moonrelay", "Twitch", 1 },
|
||||
{ 8, 5, "@hoshimimiyu", "Hoshimi Miyu", "Twitch", 2 },
|
||||
{ 9, 6, "@kurainu", "Kurainu 3D Live", "Twitch", 2 },
|
||||
{ 10, 7, "@pyonkichikingdom", "Pyonkichi Kingdom", "Twitch", 2 },
|
||||
{ 11, 8, "@aoisakura", "Aoi Sakura", "YouTube", 3 },
|
||||
{ 12, 9, "@starbyte", "Starbyte", "Twitch", 3 },
|
||||
{ 13, 10, "@tenshivox", "Tenshi Vox", "Twitch", 4 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Nominations",
|
||||
columns: new[] { "Id", "CandidateId", "CandidateText", "CategoryId", "CreatedAt", "SeasonId", "SubmittedByTwitchId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, null, "Hoshimi Miyu", 1, new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), 1, "twitch_hoshi" },
|
||||
{ 2, null, "Kurainu 3D Live", 2, new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), 1, "twitch_kurainu" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Results",
|
||||
columns: new[] { "Id", "CandidateId", "CategoryName", "SeasonId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, 8, "VTuber des Jahres", 2 },
|
||||
{ 2, 9, "Bestes Live Event", 2 },
|
||||
{ 3, 10, "Clip des Jahres", 2 },
|
||||
{ 4, 11, "VTuber des Jahres", 3 },
|
||||
{ 5, 12, "Clip des Jahres", 3 },
|
||||
{ 6, 13, "VTuber des Jahres", 4 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "VoteEntries",
|
||||
columns: new[] { "Id", "BallotId", "CandidateId", "CategoryId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, 1, 1, 1 },
|
||||
{ 2, 1, 4, 2 },
|
||||
{ 3, 2, 2, 1 },
|
||||
{ 4, 2, 6, 3 }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Candidates_CategoryId",
|
||||
table: "Candidates",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Candidates_SeasonId",
|
||||
table: "Candidates",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Categories_SeasonId_Slug",
|
||||
table: "Categories",
|
||||
columns: new[] { "SeasonId", "Slug" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_CandidateId",
|
||||
table: "Nominations",
|
||||
column: "CandidateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_CategoryId",
|
||||
table: "Nominations",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId",
|
||||
table: "Nominations",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_CandidateId",
|
||||
table: "Results",
|
||||
column: "CandidateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_SeasonId",
|
||||
table: "Results",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Seasons_Year",
|
||||
table: "Seasons",
|
||||
column: "Year",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteBallots_SeasonId",
|
||||
table: "VoteBallots",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteEntries_BallotId",
|
||||
table: "VoteEntries",
|
||||
column: "BallotId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteEntries_CandidateId",
|
||||
table: "VoteEntries",
|
||||
column: "CandidateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteEntries_CategoryId",
|
||||
table: "VoteEntries",
|
||||
column: "CategoryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Nominations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Results");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VoteEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Candidates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VoteBallots");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Categories");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddClipReviewWorkflow : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
|
||||
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
|
||||
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
DROP COLUMN IF EXISTS "ReviewNote";
|
||||
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
DROP COLUMN IF EXISTS "ReviewedByTwitchId";
|
||||
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
DROP COLUMN IF EXISTS "ReviewedAt";
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNominationReviewWorkflow : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SeasonId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ReviewNote",
|
||||
table: "Nominations",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "ReviewedAt",
|
||||
table: "Nominations",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ReviewedByTwitchId",
|
||||
table: "Nominations",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Status",
|
||||
table: "Nominations",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Nominations",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" },
|
||||
values: new object[] { null, null, null, "pending" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Nominations",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" },
|
||||
values: new object[] { null, null, null, "pending" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_Status",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "Status" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Nominations_SeasonId_Status",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReviewNote",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReviewedAt",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReviewedByTwitchId",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Status",
|
||||
table: "Nominations");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId",
|
||||
table: "Nominations",
|
||||
column: "SeasonId");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAwardResultCategoryLock : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Results_SeasonId",
|
||||
table: "Results");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CategoryId",
|
||||
table: "Results",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "Results" AS r
|
||||
SET "CategoryId" = c."Id"
|
||||
FROM "Categories" AS c
|
||||
WHERE r."SeasonId" = c."SeasonId"
|
||||
AND lower(trim(r."CategoryName")) = lower(trim(c."Name"));
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM "Results" WHERE "CategoryId" IS NULL) THEN
|
||||
RAISE EXCEPTION 'Could not backfill CategoryId for one or more rows in Results.';
|
||||
END IF;
|
||||
END $$;
|
||||
""");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "CategoryId",
|
||||
table: "Results",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "integer",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_CategoryId",
|
||||
table: "Results",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_SeasonId_CategoryId",
|
||||
table: "Results",
|
||||
columns: new[] { "SeasonId", "CategoryId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Results_Categories_CategoryId",
|
||||
table: "Results",
|
||||
column: "CategoryId",
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Results_Categories_CategoryId",
|
||||
table: "Results");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Results_CategoryId",
|
||||
table: "Results");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Results_SeasonId_CategoryId",
|
||||
table: "Results");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CategoryId",
|
||||
table: "Results");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_SeasonId",
|
||||
table: "Results",
|
||||
column: "SeasonId");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeasonShowStreamUrl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShowStreamUrl",
|
||||
table: "Seasons",
|
||||
type: "character varying(400)",
|
||||
maxLength: 400,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "ShowStreamUrl",
|
||||
value: "https://twitch.tv/jayuhime");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "ShowStreamUrl",
|
||||
value: "https://twitch.tv/jayuhime");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "ShowStreamUrl",
|
||||
value: "https://youtube.com/c/Jayuhime");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "ShowStreamUrl",
|
||||
value: "https://twitch.tv/jayuhime");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowStreamUrl",
|
||||
table: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSiteSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
HostDisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
HostTagline = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
NewsletterUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
PrivacyEmail = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
ImprintUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
ContactUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
SponsorsUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
SocialLinksJson = table.Column<string>(type: "text", nullable: false),
|
||||
FaqJson = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SiteSettings",
|
||||
columns: new[] { "Id", "ContactUrl", "FaqJson", "HostDisplayName", "HostTagline", "ImprintUrl", "NewsletterUrl", "PrivacyEmail", "SocialLinksJson", "SponsorsUrl" },
|
||||
values: new object[] { 1, "https://vtuber-star-awards.de/kontakt", "[{\"question\":\"Wer darf mitmachen?\",\"answer\":\"Jede:r mit einem Twitch-Account. Einmal einloggen gen\\u00FCgt \\u2013 kein extra Konto, kein Papierkram.\"},{\"question\":\"Wie werden die Gewinner bestimmt?\",\"answer\":\"Komplett durch eure Stimmen. Die Community entscheidet, wer auf die B\\u00FChne darf.\"},{\"question\":\"Kann ich meine Wahl noch \\u00E4ndern?\",\"answer\":\"Ja. Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen anpassen.\"},{\"question\":\"Wann und wo findet die Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und live auf den verkn\\u00FCpften Plattformen \\u00FCbertragen.\"}]", "Jayuhime", "VTuber & Award Host", "https://vtuber-star-awards.de/impressum", "https://vtuber-star-awards.de/newsletter", "datenschutz@vtuber-star-awards.de", "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\"},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\"},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\"},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\"},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\"}]", "https://vtuber-star-awards.de/partner" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1213
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrivacyPolicyContentMetadata : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PrivacyPolicyContent",
|
||||
table: "SiteSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "PrivacyPolicyUpdatedAt",
|
||||
table: "SiteSettings",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PrivacyPolicyUpdatedBy",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "PrivacyPolicyContent", "PrivacyPolicyUpdatedAt", "PrivacyPolicyUpdatedBy" },
|
||||
values: new object[] { "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "seed" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrivacyPolicyContent",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrivacyPolicyUpdatedAt",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrivacyPolicyUpdatedBy",
|
||||
table: "SiteSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOperationalSiteSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DemoLoginDisplayName",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DemoLoginEmail",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(180)",
|
||||
maxLength: 180,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "DemoLoginEnabled",
|
||||
table: "SiteSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "DemoLoginManagedByDatabase",
|
||||
table: "SiteSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DemoLoginPasswordHash",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DemoLoginPasswordSalt",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(80)",
|
||||
maxLength: 80,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DemoLoginTwitchUserId",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "MaintenanceMessage",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(600)",
|
||||
maxLength: 600,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "MaintenanceModeEnabled",
|
||||
table: "SiteSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "MaintenanceTitle",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "DemoLoginDisplayName", "DemoLoginEmail", "DemoLoginEnabled", "DemoLoginManagedByDatabase", "DemoLoginPasswordHash", "DemoLoginPasswordSalt", "DemoLoginTwitchUserId", "MaintenanceMessage", "MaintenanceModeEnabled", "MaintenanceTitle" },
|
||||
values: new object[] { "Jayuhime Admin", "", false, false, "", "", "jayuhime_admin", "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", false, "Sternenpause" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DemoLoginDisplayName",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DemoLoginEmail",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DemoLoginEnabled",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DemoLoginManagedByDatabase",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DemoLoginPasswordHash",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DemoLoginPasswordSalt",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DemoLoginTwitchUserId",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaintenanceMessage",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaintenanceModeEnabled",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaintenanceTitle",
|
||||
table: "SiteSettings");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeasonShowStartsAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE "Seasons"
|
||||
ADD COLUMN IF NOT EXISTS "ShowStartsAt" time without time zone NOT NULL DEFAULT TIME '20:00:00';
|
||||
""");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "ShowStartsAt",
|
||||
value: new TimeOnly(20, 0, 0));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "ShowStartsAt",
|
||||
value: new TimeOnly(20, 0, 0));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "ShowStartsAt",
|
||||
value: new TimeOnly(20, 0, 0));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Seasons",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "ShowStartsAt",
|
||||
value: new TimeOnly(20, 0, 0));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowStartsAt",
|
||||
table: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddClipCandidateLink : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
ADD COLUMN IF NOT EXISTS "CandidateId" integer NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('"ClipSubmissions"') IS NOT NULL THEN
|
||||
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
|
||||
ON "ClipSubmissions" ("CandidateId");
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId'
|
||||
) THEN
|
||||
ALTER TABLE "ClipSubmissions"
|
||||
ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId"
|
||||
FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id")
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
DROP CONSTRAINT IF EXISTS "FK_ClipSubmissions_Candidates_CandidateId";
|
||||
|
||||
DROP INDEX IF EXISTS "IX_ClipSubmissions_CandidateId";
|
||||
|
||||
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||
DROP COLUMN IF EXISTS "CandidateId";
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AwardsDbContext))]
|
||||
[Migration("20260624150500_AddRiskFlagReviewNote")]
|
||||
public partial class AddRiskFlagReviewNote : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "RiskFlags"
|
||||
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "RiskFlags"
|
||||
DROP COLUMN IF EXISTS "ReviewNote";
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AwardsDbContext))]
|
||||
[Migration("20260624162000_AddRiskRulesJson")]
|
||||
public partial class AddRiskRulesJson : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
$"""
|
||||
ALTER TABLE IF EXISTS "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '{RiskRuleSettings.Serialize(RiskRuleSettings.Defaults).Replace("'", "''")}';
|
||||
|
||||
UPDATE "SiteSettings"
|
||||
SET "RiskRulesJson" = '{RiskRuleSettings.Serialize(RiskRuleSettings.Defaults).Replace("'", "''")}'
|
||||
WHERE "RiskRulesJson" IS NULL OR btrim("RiskRulesJson") = '' OR "RiskRulesJson" = '[]';
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "SiteSettings"
|
||||
DROP COLUMN IF EXISTS "RiskRulesJson";
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AwardsDbContext))]
|
||||
[Migration("20260625110000_AddFooterPageContent")]
|
||||
public partial class AddFooterPageContent : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
var imprintContent = SeedCatalog.DefaultImprintContent.Replace("'", "''");
|
||||
var contactContent = SeedCatalog.DefaultContactContent.Replace("'", "''");
|
||||
var sponsorsContent = SeedCatalog.DefaultSponsorsContent.Replace("'", "''");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
$"""
|
||||
ALTER TABLE IF EXISTS "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS "ContactContent" text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE "SiteSettings"
|
||||
SET "ImprintContent" = '{imprintContent}'
|
||||
WHERE "ImprintContent" IS NULL OR btrim("ImprintContent") = '';
|
||||
|
||||
UPDATE "SiteSettings"
|
||||
SET "ContactContent" = '{contactContent}'
|
||||
WHERE "ContactContent" IS NULL OR btrim("ContactContent") = '';
|
||||
|
||||
UPDATE "SiteSettings"
|
||||
SET "SponsorsContent" = '{sponsorsContent}'
|
||||
WHERE "SponsorsContent" IS NULL OR btrim("SponsorsContent") = '';
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE IF EXISTS "SiteSettings"
|
||||
DROP COLUMN IF EXISTS "ImprintContent",
|
||||
DROP COLUMN IF EXISTS "ContactContent",
|
||||
DROP COLUMN IF EXISTS "SponsorsContent";
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTeamManagement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeamMembers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Login = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Role = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
PasswordSalt = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
MustChangePassword = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
UpdatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastLoginAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
PasswordResetAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TeamMembers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeamRolePermissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Role = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
PermissionsJson = table.Column<string>(type: "text", nullable: false),
|
||||
UpdatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TeamRolePermissions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeamMembers_Login",
|
||||
table: "TeamMembers",
|
||||
column: "Login",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeamRolePermissions_Role",
|
||||
table: "TeamRolePermissions",
|
||||
column: "Role",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeamMembers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeamRolePermissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AwardsDbContext))]
|
||||
[Migration("20260625153000_AddCreatorRoleAndTeamTwitchBinding")]
|
||||
public partial class AddCreatorRoleAndTeamTwitchBinding : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BoundTwitchDisplayName",
|
||||
table: "TeamMembers",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BoundTwitchUserId",
|
||||
table: "TeamMembers",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "TwitchBoundAt",
|
||||
table: "TeamMembers",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeamMembers_BoundTwitchUserId",
|
||||
table: "TeamMembers",
|
||||
column: "BoundTwitchUserId",
|
||||
unique: true,
|
||||
filter: "\"BoundTwitchUserId\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_TeamMembers_BoundTwitchUserId",
|
||||
table: "TeamMembers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BoundTwitchDisplayName",
|
||||
table: "TeamMembers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BoundTwitchUserId",
|
||||
table: "TeamMembers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchBoundAt",
|
||||
table: "TeamMembers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVoteBallotSubmitterUniqueIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VoteBallots_SeasonId",
|
||||
table: "VoteBallots");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TwitchAuthManagedByDatabase",
|
||||
table: "SiteSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchClientId",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchClientSecret",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(180)",
|
||||
maxLength: 180,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchRedirectUri",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(400)",
|
||||
maxLength: 400,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchScope",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(300)",
|
||||
maxLength: 300,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "TwitchAuthManagedByDatabase", "TwitchClientId", "TwitchClientSecret", "TwitchRedirectUri", "TwitchScope" },
|
||||
values: new object[] { false, "", "", "", "" });
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM "VoteEntries"
|
||||
WHERE "BallotId" IN (
|
||||
SELECT "Id"
|
||||
FROM (
|
||||
SELECT
|
||||
"Id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "SeasonId", "SubmittedByTwitchId"
|
||||
ORDER BY "SubmittedAt" DESC, "Id" DESC
|
||||
) AS duplicate_rank
|
||||
FROM "VoteBallots"
|
||||
) ranked_ballots
|
||||
WHERE duplicate_rank > 1
|
||||
);
|
||||
|
||||
DELETE FROM "VoteBallots"
|
||||
WHERE "Id" IN (
|
||||
SELECT "Id"
|
||||
FROM (
|
||||
SELECT
|
||||
"Id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "SeasonId", "SubmittedByTwitchId"
|
||||
ORDER BY "SubmittedAt" DESC, "Id" DESC
|
||||
) AS duplicate_rank
|
||||
FROM "VoteBallots"
|
||||
) ranked_ballots
|
||||
WHERE duplicate_rank > 1
|
||||
);
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
|
||||
table: "VoteBallots",
|
||||
columns: new[] { "SeasonId", "SubmittedByTwitchId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
|
||||
table: "VoteBallots");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchAuthManagedByDatabase",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchClientId",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchClientSecret",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchRedirectUri",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchScope",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteBallots_SeasonId",
|
||||
table: "VoteBallots",
|
||||
column: "SeasonId");
|
||||
}
|
||||
}
|
||||
}
|
||||
+596
-464
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,795 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCleanBaseline : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AdminAuditEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
AdminTwitchUserId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ActionType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
EntityType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
EntityId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Summary = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
MetadataJson = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedFromIp = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
UserAgent = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AdminAuditEntries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Seasons",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Year = table.Column<int>(type: "integer", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
IsDemo = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
IsCurrent = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsCommunityOnly = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CurrentPhase = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||
NominationStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
NominationEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
VotingStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
VotingEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ReviewStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ReviewEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ShowDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ShowStartsAt = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
WinnersPublishedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
WinnersPublishedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
SubcategoryTemplatesJson = table.Column<string>(type: "text", nullable: false, defaultValue: "[]"),
|
||||
WorkflowRulesJson = table.Column<string>(type: "text", nullable: false, defaultValue: "[]")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Seasons", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
HostDisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
HostTagline = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
NewsletterUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
ShareXUrl = table.Column<string>(type: "text", nullable: false),
|
||||
ShareDiscordUrl = table.Column<string>(type: "text", nullable: false),
|
||||
PrivacyEmail = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
PrivacyPolicyContent = table.Column<string>(type: "text", nullable: false),
|
||||
PrivacyPolicyUpdatedBy = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
PrivacyPolicyUpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ImprintUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
ImprintContent = table.Column<string>(type: "text", nullable: false),
|
||||
ContactUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
ContactContent = table.Column<string>(type: "text", nullable: false),
|
||||
SponsorsUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
SponsorsContent = table.Column<string>(type: "text", nullable: false),
|
||||
ShowactsUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
ShowactsContent = table.Column<string>(type: "text", nullable: false, defaultValue: ""),
|
||||
StreamBannerEyebrow = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
StreamBannerTitle = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
StreamBannerText = table.Column<string>(type: "text", nullable: false),
|
||||
StreamBannerLiveButtonLabel = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
StreamBannerLiveButtonUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
StreamBannerLockedButtonLabel = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
StreamBannerUseCompletedContent = table.Column<bool>(type: "boolean", nullable: false),
|
||||
StreamBannerCompletedEyebrow = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
StreamBannerCompletedTitle = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
StreamBannerCompletedText = table.Column<string>(type: "text", nullable: false),
|
||||
StreamBannerCompletedButtonLabel = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
StreamBannerCompletedButtonUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
AwardsSectionTitle = table.Column<string>(type: "text", nullable: false),
|
||||
AwardsSectionDescription = table.Column<string>(type: "text", nullable: false),
|
||||
SubcategoriesSectionTitle = table.Column<string>(type: "text", nullable: false),
|
||||
SubcategoriesSectionDescription = table.Column<string>(type: "text", nullable: false),
|
||||
SocialLinksJson = table.Column<string>(type: "text", nullable: false),
|
||||
FaqJson = table.Column<string>(type: "text", nullable: false),
|
||||
RiskRulesJson = table.Column<string>(type: "text", nullable: false),
|
||||
WorkflowRulesJson = table.Column<string>(type: "text", nullable: false, defaultValue: "[]"),
|
||||
TrackingRulesJson = table.Column<string>(type: "text", nullable: false, defaultValue: "[]"),
|
||||
ViewerStatsProviderBaseUrl = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
TrackingReviewNotes = table.Column<string>(type: "text", nullable: false),
|
||||
NominationLinkBlacklistJson = table.Column<string>(type: "text", nullable: false, defaultValue: "[]"),
|
||||
ClipSubmissionsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
ClipReviewEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
ClipAdminMenuVisible = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ClipSubmissionDisabledMessage = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
ShowactApplicationsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
ShowactApplicationStartsAt = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
ShowactApplicationEndsAt = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
ShowactApplicationDisabledMessage = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
ShowactFormSchemaJson = table.Column<string>(type: "text", nullable: false),
|
||||
SponsorsVisible = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
DemoLoginManagedByDatabase = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DemoLoginEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DemoLoginEmail = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
|
||||
DemoLoginPasswordHash = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
DemoLoginPasswordSalt = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
DemoLoginTwitchUserId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
DemoLoginDisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
TwitchAuthManagedByDatabase = table.Column<bool>(type: "boolean", nullable: false),
|
||||
TwitchClientId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
TwitchClientSecret = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
|
||||
TwitchRedirectUri = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
TwitchScope = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||
SessionIdleTimeoutHours = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
|
||||
MaintenanceModeEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
MaintenanceTitle = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
MaintenanceMessage = table.Column<string>(type: "character varying(600)", maxLength: 600, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StreamerIdentities",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Platform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
Login = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
NormalizedKey = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ProfileUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
LastResolvedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StreamerIdentities", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeamMembers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Login = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Role = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
PasswordSalt = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
BoundTwitchUserId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
BoundTwitchDisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
MustChangePassword = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
UpdatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastLoginAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
TwitchBoundAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
PasswordResetAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TeamMembers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeamRolePermissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Role = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
PermissionsJson = table.Column<string>(type: "text", nullable: false),
|
||||
UpdatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TeamRolePermissions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserSessions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SessionToken = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
TwitchUserId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Role = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
CreatedFromIp = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
UserAgent = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
LastSeenAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Categories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
GroupName = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Slug = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
MaxNomineesPerUser = table.Column<int>(type: "integer", nullable: false),
|
||||
ViewerRangeMin = table.Column<int>(type: "integer", nullable: true),
|
||||
ViewerRangeMax = table.Column<int>(type: "integer", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Categories", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Categories_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RiskFlags",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: true),
|
||||
TwitchUserId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
Source = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
Severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Summary = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
CreatedFromIp = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
UserAgent = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
MetadataJson = table.Column<string>(type: "text", nullable: false),
|
||||
ReviewNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ReviewedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RiskFlags", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RiskFlags_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShowactApplications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
ArtistName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ContactEmail = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
|
||||
ContactDiscord = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
PlatformUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
PerformanceType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||
TechnicalNotes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||
ReferenceUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
FieldResponsesJson = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ReviewNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ReviewedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
CreatedFromIp = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
UserAgent = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShowactApplications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ShowactApplications_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Sponsors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
WebsiteUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
LogoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
Tier = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
IsVisible = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Sponsors", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Sponsors_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VoteBallots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubmittedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
SubmittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VoteBallots", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteBallots_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Candidates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
StreamerIdentityId = table.Column<int>(type: "integer", nullable: true),
|
||||
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ChannelSlug = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Platform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
NominationTally = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||
AcceptanceStatus = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false, defaultValue: "open"),
|
||||
AcceptanceNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ClipCompilationUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ClipCompilationTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
ClipCompilationPlatform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: true),
|
||||
ClipEmbedStatus = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false, defaultValue: "unchecked")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Candidates", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Candidates_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Candidates_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Candidates_StreamerIdentities_StreamerIdentityId",
|
||||
column: x => x.StreamerIdentityId,
|
||||
principalTable: "StreamerIdentities",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClipSubmissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: true),
|
||||
CandidateId = table.Column<int>(type: "integer", nullable: true),
|
||||
SubmittedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
ClipUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Creator = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Platform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ReviewNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ReviewedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
CreatedFromIp = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClipSubmissions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClipSubmissions_Candidates_CandidateId",
|
||||
column: x => x.CandidateId,
|
||||
principalTable: "Candidates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClipSubmissions_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Nominations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: true),
|
||||
CategoryGroupName = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false, defaultValue: ""),
|
||||
SubmittedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
CandidateId = table.Column<int>(type: "integer", nullable: true),
|
||||
StreamerIdentityId = table.Column<int>(type: "integer", nullable: true),
|
||||
SuggestedCategoryId = table.Column<int>(type: "integer", nullable: true),
|
||||
CandidateText = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
StreamUrl = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
ResolvedChannel = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
ResolvedPlatform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: true),
|
||||
AvgViewers = table.Column<int>(type: "integer", nullable: true),
|
||||
HoursStreamed = table.Column<int>(type: "integer", nullable: true),
|
||||
HoursWatched = table.Column<int>(type: "integer", nullable: true),
|
||||
PeakViewers = table.Column<int>(type: "integer", nullable: true),
|
||||
FollowersGained = table.Column<int>(type: "integer", nullable: true),
|
||||
TrackerStatus = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false, defaultValue: "pending"),
|
||||
TrackerCheckedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
TrackingReviewStatus = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false, defaultValue: "clear"),
|
||||
TrackingFlagsJson = table.Column<string>(type: "text", nullable: false, defaultValue: "[]"),
|
||||
TrackingReviewNote = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
TrackingReviewedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
TrackingReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ReviewNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ReviewedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Nominations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_Candidates_CandidateId",
|
||||
column: x => x.CandidateId,
|
||||
principalTable: "Candidates",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_Categories_SuggestedCategoryId",
|
||||
column: x => x.SuggestedCategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Nominations_StreamerIdentities_StreamerIdentityId",
|
||||
column: x => x.StreamerIdentityId,
|
||||
principalTable: "StreamerIdentities",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Results",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SeasonId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
CandidateId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Results", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Results_Candidates_CandidateId",
|
||||
column: x => x.CandidateId,
|
||||
principalTable: "Candidates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Results_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Results_Seasons_SeasonId",
|
||||
column: x => x.SeasonId,
|
||||
principalTable: "Seasons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VoteEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BallotId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
CandidateId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VoteEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteEntries_Candidates_CandidateId",
|
||||
column: x => x.CandidateId,
|
||||
principalTable: "Candidates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteEntries_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VoteEntries_VoteBallots_BallotId",
|
||||
column: x => x.BallotId,
|
||||
principalTable: "VoteBallots",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Candidates_CategoryId",
|
||||
table: "Candidates",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Candidates_SeasonId",
|
||||
table: "Candidates",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Candidates_StreamerIdentityId",
|
||||
table: "Candidates",
|
||||
column: "StreamerIdentityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Categories_SeasonId_Slug",
|
||||
table: "Categories",
|
||||
columns: new[] { "SeasonId", "Slug" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClipSubmissions_CandidateId",
|
||||
table: "ClipSubmissions",
|
||||
column: "CandidateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClipSubmissions_SeasonId_Status",
|
||||
table: "ClipSubmissions",
|
||||
columns: new[] { "SeasonId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_CandidateId",
|
||||
table: "Nominations",
|
||||
column: "CandidateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_CategoryId",
|
||||
table: "Nominations",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_CategoryGroupName_Status",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "CategoryGroupName", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_Status",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName",
|
||||
table: "Nominations",
|
||||
columns: new[] { "SeasonId", "StreamerIdentityId", "CategoryGroupName" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_StreamerIdentityId",
|
||||
table: "Nominations",
|
||||
column: "StreamerIdentityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Nominations_SuggestedCategoryId",
|
||||
table: "Nominations",
|
||||
column: "SuggestedCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_CandidateId",
|
||||
table: "Results",
|
||||
column: "CandidateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_CategoryId",
|
||||
table: "Results",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_SeasonId_CategoryId",
|
||||
table: "Results",
|
||||
columns: new[] { "SeasonId", "CategoryId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RiskFlags_SeasonId",
|
||||
table: "RiskFlags",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Seasons_Year",
|
||||
table: "Seasons",
|
||||
column: "Year",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShowactApplications_SeasonId_Status",
|
||||
table: "ShowactApplications",
|
||||
columns: new[] { "SeasonId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Sponsors_SeasonId_IsVisible_SortOrder",
|
||||
table: "Sponsors",
|
||||
columns: new[] { "SeasonId", "IsVisible", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StreamerIdentities_NormalizedKey",
|
||||
table: "StreamerIdentities",
|
||||
column: "NormalizedKey",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeamMembers_BoundTwitchUserId",
|
||||
table: "TeamMembers",
|
||||
column: "BoundTwitchUserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeamMembers_Login",
|
||||
table: "TeamMembers",
|
||||
column: "Login",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeamRolePermissions_Role",
|
||||
table: "TeamRolePermissions",
|
||||
column: "Role",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserSessions_SessionToken",
|
||||
table: "UserSessions",
|
||||
column: "SessionToken",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
|
||||
table: "VoteBallots",
|
||||
columns: new[] { "SeasonId", "SubmittedByTwitchId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteEntries_BallotId",
|
||||
table: "VoteEntries",
|
||||
column: "BallotId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteEntries_CandidateId",
|
||||
table: "VoteEntries",
|
||||
column: "CandidateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteEntries_CategoryId",
|
||||
table: "VoteEntries",
|
||||
column: "CategoryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AdminAuditEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClipSubmissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Nominations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Results");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RiskFlags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ShowactApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Sponsors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeamMembers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeamRolePermissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserSessions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VoteEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Candidates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VoteBallots");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Categories");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StreamerIdentities");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Seasons");
|
||||
}
|
||||
}
|
||||
}
|
||||
+731
-460
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations;
|
||||
|
||||
public partial class SeedApplicationDefaults : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO "SiteSettings" (
|
||||
"Id",
|
||||
"HostDisplayName",
|
||||
"HostTagline",
|
||||
"NewsletterUrl",
|
||||
"ShareXUrl",
|
||||
"ShareDiscordUrl",
|
||||
"PrivacyEmail",
|
||||
"PrivacyPolicyContent",
|
||||
"ImprintUrl",
|
||||
"ImprintContent",
|
||||
"ContactUrl",
|
||||
"ContactContent",
|
||||
"SponsorsUrl",
|
||||
"SponsorsContent",
|
||||
"ShowactsUrl",
|
||||
"ShowactsContent",
|
||||
"StreamBannerEyebrow",
|
||||
"StreamBannerTitle",
|
||||
"StreamBannerText",
|
||||
"StreamBannerLiveButtonLabel",
|
||||
"StreamBannerLiveButtonUrl",
|
||||
"StreamBannerLockedButtonLabel",
|
||||
"StreamBannerUseCompletedContent",
|
||||
"StreamBannerCompletedEyebrow",
|
||||
"StreamBannerCompletedTitle",
|
||||
"StreamBannerCompletedText",
|
||||
"StreamBannerCompletedButtonLabel",
|
||||
"StreamBannerCompletedButtonUrl",
|
||||
"AwardsSectionTitle",
|
||||
"AwardsSectionDescription",
|
||||
"SubcategoriesSectionTitle",
|
||||
"SubcategoriesSectionDescription",
|
||||
"SocialLinksJson",
|
||||
"FaqJson",
|
||||
"RiskRulesJson",
|
||||
"WorkflowRulesJson",
|
||||
"TrackingRulesJson",
|
||||
"ViewerStatsProviderBaseUrl",
|
||||
"TrackingReviewNotes",
|
||||
"NominationLinkBlacklistJson",
|
||||
"ClipSubmissionsEnabled",
|
||||
"ClipReviewEnabled",
|
||||
"ClipAdminMenuVisible",
|
||||
"ClipSubmissionDisabledMessage",
|
||||
"ShowactApplicationsEnabled",
|
||||
"ShowactApplicationStartsAt",
|
||||
"ShowactApplicationEndsAt",
|
||||
"ShowactApplicationDisabledMessage",
|
||||
"ShowactFormSchemaJson",
|
||||
"SponsorsVisible",
|
||||
"DemoLoginManagedByDatabase",
|
||||
"DemoLoginEnabled",
|
||||
"DemoLoginEmail",
|
||||
"DemoLoginPasswordHash",
|
||||
"DemoLoginPasswordSalt",
|
||||
"DemoLoginTwitchUserId",
|
||||
"DemoLoginDisplayName",
|
||||
"TwitchAuthManagedByDatabase",
|
||||
"TwitchClientId",
|
||||
"TwitchClientSecret",
|
||||
"TwitchRedirectUri",
|
||||
"TwitchScope",
|
||||
"SessionIdleTimeoutHours",
|
||||
"MaintenanceModeEnabled",
|
||||
"MaintenanceTitle",
|
||||
"MaintenanceMessage"
|
||||
)
|
||||
VALUES (
|
||||
1,
|
||||
'Jayuhime',
|
||||
'VTuber Star Awards',
|
||||
'',
|
||||
'https://x.com/intent/tweet',
|
||||
'https://discord.gg/',
|
||||
'privacy@example.invalid',
|
||||
'Diese lokale Baseline enthaelt nur nicht-geheime Platzhalter. Pflege produktive Datenschutztexte vor dem Go-live im Admin-Panel.',
|
||||
'/impressum',
|
||||
'Impressumsdaten werden vor dem Go-live im Admin-Panel gepflegt.',
|
||||
'/kontakt',
|
||||
'Fragen zu den Awards und Partnerschaften koennen ueber die offiziellen Kontaktkanaele gestellt werden.',
|
||||
'/sponsoren',
|
||||
'Partner und Sponsoren der aktuellen Award-Season.',
|
||||
'/showacts',
|
||||
'Showact-Bewerbungen fuer das Finale werden hier verwaltet.',
|
||||
'Finale live',
|
||||
'Die Award-Show startet im Livestream',
|
||||
'Wenn das Finale freigeschaltet ist, fuehrt dieser Button zum offiziellen Stream. Der Link wird zentral im Landingpage Stream-Banner gepflegt.',
|
||||
'Zum finalen Stream',
|
||||
'https://twitch.tv/jayuhime',
|
||||
'Stream noch nicht freigegeben',
|
||||
TRUE,
|
||||
'Finale abgeschlossen',
|
||||
'Danke fuer diese Award-Nacht',
|
||||
'Die Gewinner:innen bleiben im Archiv sichtbar. Highlights und VODs koennen hier verlinkt werden.',
|
||||
'Highlights ansehen',
|
||||
'',
|
||||
'Award-Kategorien',
|
||||
'Die Kategorien bilden Community-Leistung, Content-Qualitaet und besondere Momente der Season ab.',
|
||||
'Unterkategorien',
|
||||
'Unterkategorien helfen dem Team, Nominierungen sauber zu reviewen und faire Finalfelder zu bauen.',
|
||||
'[{"label":"Twitch","url":"https://twitch.tv/jayuhime"},{"label":"Discord","url":"https://discord.gg/"},{"label":"X","url":"https://x.com/"}]',
|
||||
'[{"question":"Wann startet die naechste Phase?","answer":"Die aktuellen Termine stehen auf der Landingpage und im Admin-Jahresplan."},{"question":"Wie werden Gewinner:innen bestimmt?","answer":"Nominierungen, Review und Voting laufen phasenweise. Das Team prueft Finalfelder vor der Veroeffentlichung."},{"question":"Wo pflege ich den finalen Stream-Link?","answer":"Der Stream-Link wird zentral im Landingpage Stream-Banner gepflegt."}]',
|
||||
'[]',
|
||||
'[]',
|
||||
'[]',
|
||||
'https://twitchtracker.com/api',
|
||||
'Automatische TwitchTracker-Werte dienen als Review-Hilfe. Bei fehlenden oder unvollstaendigen Daten entscheidet das Team manuell.',
|
||||
'[{"pattern":"localhost","reason":"Lokale Testlinks werden im Review blockiert."},{"pattern":"example.com","reason":"Platzhalterlinks sollen nicht als echte Nominierung freigegeben werden."}]',
|
||||
TRUE,
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Clip-Einreichungen sind aktuell geschlossen.',
|
||||
TRUE,
|
||||
DATE '2026-06-01',
|
||||
DATE '2026-08-15',
|
||||
'Showact-Bewerbungen sind aktuell geschlossen.',
|
||||
'[{"key":"performanceLength","label":"Geplante Laenge","type":"text","required":true},{"key":"contentRating","label":"Content-Hinweise","type":"textarea","required":false}]',
|
||||
TRUE,
|
||||
FALSE,
|
||||
FALSE,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'jayuhime_admin',
|
||||
'Jayuhime Admin',
|
||||
FALSE,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'user:read:email',
|
||||
3,
|
||||
FALSE,
|
||||
'Sternenpause',
|
||||
'Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.'
|
||||
)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
|
||||
SELECT setval(pg_get_serial_sequence('"SiteSettings"', 'Id'), COALESCE((SELECT MAX("Id") FROM "SiteSettings"), 1));
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DELETE FROM "SiteSettings"
|
||||
WHERE "Id" = 1
|
||||
AND "HostDisplayName" = 'Jayuhime'
|
||||
AND "DemoLoginPasswordHash" = ''
|
||||
AND "TwitchClientSecret" = '';
|
||||
"""
|
||||
);
|
||||
}
|
||||
}
|
||||
+746
-460
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations;
|
||||
|
||||
public partial class SeedRealisticDemoScenario : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO "Seasons" (
|
||||
"Id", "Year", "Name", "IsDemo", "IsCurrent", "IsCommunityOnly", "CurrentPhase",
|
||||
"NominationStartsAt", "NominationEndsAt", "VotingStartsAt", "VotingEndsAt",
|
||||
"ReviewStartsAt", "ReviewEndsAt", "ShowDate", "ShowStartsAt",
|
||||
"WinnersPublishedAt", "WinnersPublishedByTwitchId", "SubcategoryTemplatesJson", "WorkflowRulesJson"
|
||||
)
|
||||
VALUES
|
||||
(1000, 2025, 'VTuber Star Awards 2025 Demo Archiv', TRUE, FALSE, FALSE, 'Archiv',
|
||||
DATE '2025-04-01', DATE '2025-04-28', DATE '2025-05-10', DATE '2025-05-24',
|
||||
DATE '2025-04-29', DATE '2025-05-09', DATE '2025-06-21', TIME '20:00',
|
||||
TIMESTAMPTZ '2025-06-22 10:00:00+00', 'demo_owner', '[]', '[]'),
|
||||
(1001, 2026, 'VTuber Star Awards 2026 Demo', TRUE, TRUE, FALSE, 'Voting',
|
||||
DATE '2026-05-18', DATE '2026-06-16', DATE '2026-06-24', DATE '2026-07-19',
|
||||
DATE '2026-06-17', DATE '2026-06-23', DATE '2026-08-08', TIME '20:00',
|
||||
NULL, NULL, '[]', '[]');
|
||||
|
||||
INSERT INTO "Categories" (
|
||||
"Id", "SeasonId", "GroupName", "Name", "Slug", "Description", "SortOrder", "MaxNomineesPerUser", "ViewerRangeMin", "ViewerRangeMax"
|
||||
)
|
||||
VALUES
|
||||
(1101, 1001, 'Spotlight', 'Rising Star', 'rising-star', 'Neue oder stark gewachsene Creator:innen mit klarer Entwicklung.', 10, 2, 0, 75),
|
||||
(1102, 1001, 'Spotlight', 'Community Heart', 'community-heart', 'Creator:innen, deren Community besonders sichtbar und einladend ist.', 20, 2, 0, 200),
|
||||
(1103, 1001, 'Spotlight', 'Breakout Moment', 'breakout-moment', 'Ein einzelner Moment, Clip oder Stream, der die Season gepraegt hat.', 30, 2, 0, NULL),
|
||||
(1104, 1001, 'Content', 'Best Variety Stream', 'best-variety-stream', 'Abwechslungsreiche Streams mit sicherem roten Faden.', 40, 2, 30, 350),
|
||||
(1105, 1001, 'Content', 'Best Gaming Stream', 'best-gaming-stream', 'Gaming-Streams mit starker Moderation und guter Dramaturgie.', 50, 2, 25, 400),
|
||||
(1106, 1001, 'Content', 'Best Music Performance', 'best-music-performance', 'Live-Gesang, Instrumente oder Musikproduktion im Stream.', 60, 2, 0, 250),
|
||||
(1107, 1001, 'Content', 'Best Art Stream', 'best-art-stream', 'Art-, Design- oder Rigging-Streams mit nachvollziehbarem Prozess.', 70, 2, 0, 250),
|
||||
(1108, 1001, 'Content', 'Best Lore Project', 'best-lore-project', 'Storytelling, Lore-Events oder immersive Formatideen.', 80, 2, 0, 300),
|
||||
(1109, 1001, 'Engagement', 'Best Chat Interaction', 'best-chat-interaction', 'Besonders gute Einbindung von Chat und Community.', 90, 2, 20, 500),
|
||||
(1110, 1001, 'Engagement', 'Best Collab Energy', 'best-collab-energy', 'Kollaborationen, die alle Beteiligten staerker gemacht haben.', 100, 2, 30, 650),
|
||||
(1111, 1001, 'Engagement', 'Best Community Event', 'best-community-event', 'Community-Events mit guter Planung und nachhaltiger Wirkung.', 110, 2, 25, 750),
|
||||
(1112, 1001, 'Production', 'Best Stream Design', 'best-stream-design', 'Overlay, Szenen, Alerts und visuelle Identitaet.', 120, 2, 0, 400),
|
||||
(1113, 1001, 'Production', 'Best Original Clip', 'best-original-clip', 'Einreichbare Clips mit starkem Timing oder besonderem Moment.', 130, 2, 0, NULL),
|
||||
(1114, 1001, 'Production', 'Best Technical Glow-Up', 'best-technical-glow-up', 'Messbare Verbesserungen bei Audio, Video, Licht oder Setup.', 140, 2, 0, 300),
|
||||
(1201, 1000, 'Archiv Spotlight', 'Archiv Rising Star', 'archiv-rising-star', 'Archivkategorie fuer Gewinner-Showcase.', 10, 2, 0, 75),
|
||||
(1202, 1000, 'Archiv Content', 'Archiv Variety', 'archiv-variety', 'Archivkategorie fuer Gewinner-Showcase.', 20, 2, 0, 350),
|
||||
(1203, 1000, 'Archiv Content', 'Archiv Music', 'archiv-music', 'Archivkategorie fuer Gewinner-Showcase.', 30, 2, 0, 250),
|
||||
(1204, 1000, 'Archiv Engagement', 'Archiv Community', 'archiv-community', 'Archivkategorie fuer Gewinner-Showcase.', 40, 2, 0, 500),
|
||||
(1205, 1000, 'Archiv Production', 'Archiv Stream Design', 'archiv-stream-design', 'Archivkategorie fuer Gewinner-Showcase.', 50, 2, 0, 400),
|
||||
(1206, 1000, 'Archiv Moment', 'Archiv Clip Moment', 'archiv-clip-moment', 'Archivkategorie fuer Gewinner-Showcase.', 60, 2, 0, NULL);
|
||||
|
||||
INSERT INTO "StreamerIdentities" ("Id", "Platform", "Login", "NormalizedKey", "DisplayName", "ProfileUrl", "LastResolvedAt")
|
||||
VALUES
|
||||
(3001, 'Twitch', 'aki_lumina', 'twitch:aki_lumina', 'Aki Lumina', 'https://twitch.tv/aki_lumina', TIMESTAMPTZ '2026-06-27 12:00:00+00'),
|
||||
(3002, 'Twitch', 'mira_orbit', 'twitch:mira_orbit', 'Mira Orbit', 'https://twitch.tv/mira_orbit', TIMESTAMPTZ '2026-06-27 12:02:00+00'),
|
||||
(3003, 'Twitch', 'nova_nym', 'twitch:nova_nym', 'Nova Nym', 'https://twitch.tv/nova_nym', TIMESTAMPTZ '2026-06-27 12:04:00+00'),
|
||||
(3004, 'Twitch', 'luna_koi', 'twitch:luna_koi', 'Luna Koi', 'https://twitch.tv/luna_koi', TIMESTAMPTZ '2026-06-27 12:06:00+00'),
|
||||
(3005, 'Twitch', 'runa_bits', 'twitch:runa_bits', 'Runa Bits', 'https://twitch.tv/runa_bits', TIMESTAMPTZ '2026-06-27 12:08:00+00'),
|
||||
(3006, 'Twitch', 'sora_slate', 'twitch:sora_slate', 'Sora Slate', 'https://twitch.tv/sora_slate', TIMESTAMPTZ '2026-06-27 12:10:00+00'),
|
||||
(3007, 'Twitch', 'ember_vail', 'twitch:ember_vail', 'Ember Vail', 'https://twitch.tv/ember_vail', TIMESTAMPTZ '2026-06-27 12:12:00+00'),
|
||||
(3008, 'Twitch', 'niko_noct', 'twitch:niko_noct', 'Niko Noct', 'https://twitch.tv/niko_noct', TIMESTAMPTZ '2026-06-27 12:14:00+00'),
|
||||
(3009, 'Twitch', 'pixel_poppy', 'twitch:pixel_poppy', 'Pixel Poppy', 'https://twitch.tv/pixel_poppy', TIMESTAMPTZ '2026-06-27 12:16:00+00'),
|
||||
(3010, 'Twitch', 'hana_hertz', 'twitch:hana_hertz', 'Hana Hertz', 'https://twitch.tv/hana_hertz', TIMESTAMPTZ '2026-06-27 12:18:00+00');
|
||||
|
||||
INSERT INTO "Candidates" (
|
||||
"Id", "SeasonId", "CategoryId", "StreamerIdentityId", "DisplayName", "ChannelSlug", "Platform",
|
||||
"NominationTally", "AcceptanceStatus", "AcceptanceNote", "ClipCompilationUrl", "ClipCompilationTitle", "ClipCompilationPlatform", "ClipEmbedStatus"
|
||||
)
|
||||
VALUES
|
||||
(2001, 1001, 1101, 3001, 'Aki Lumina', 'aki_lumina', 'Twitch', 9, 'approved', 'Bestaetigt, kleiner Kanal mit starkem Wachstum.', 'https://youtu.be/demo-aki-rise', 'Aki Lumina Rising Star Reel', 'YouTube', 'available'),
|
||||
(2002, 1001, 1101, 3002, 'Mira Orbit', 'mira_orbit', 'Twitch', 7, 'approved', 'Bestaetigt.', NULL, NULL, NULL, 'unchecked'),
|
||||
(2003, 1001, 1101, 3003, 'Nova Nym', 'nova_nym', 'Twitch', 5, 'pending', 'Wartet auf finalen Clip.', NULL, NULL, NULL, 'unchecked'),
|
||||
(2004, 1001, 1102, 3004, 'Luna Koi', 'luna_koi', 'Twitch', 11, 'approved', 'Community-Belege im Review notiert.', NULL, NULL, NULL, 'unchecked'),
|
||||
(2005, 1001, 1102, 3005, 'Runa Bits', 'runa_bits', 'Twitch', 8, 'approved', 'Starke Discord-Aktion.', NULL, NULL, NULL, 'unchecked'),
|
||||
(2006, 1001, 1102, 3006, 'Sora Slate', 'sora_slate', 'Twitch', 4, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2007, 1001, 1103, 3007, 'Ember Vail', 'ember_vail', 'Twitch', 10, 'approved', 'Clip-Quelle vorhanden.', 'https://clips.twitch.tv/demo-ember-moment', 'Ember Vail Breakout Clip', 'Twitch', 'available'),
|
||||
(2008, 1001, 1103, 3008, 'Niko Noct', 'niko_noct', 'Twitch', 6, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2009, 1001, 1103, 3009, 'Pixel Poppy', 'pixel_poppy', 'Twitch', 5, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2010, 1001, 1104, 3010, 'Hana Hertz', 'hana_hertz', 'Twitch', 12, 'approved', 'Variety-Plan sauber dokumentiert.', 'https://youtu.be/demo-hana-variety', 'Hana Hertz Variety Reel', 'YouTube', 'available'),
|
||||
(2011, 1001, 1104, NULL, 'Kira Comet', 'kira_comet', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2012, 1001, 1104, NULL, 'Mochi Vale', 'mochi_vale', 'Twitch', 7, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2013, 1001, 1105, NULL, 'Taro Tactics', 'taro_tactics', 'Twitch', 10, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2014, 1001, 1105, NULL, 'Yuna Quest', 'yuna_quest', 'Twitch', 7, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2015, 1001, 1105, NULL, 'Rin Replay', 'rin_replay', 'Twitch', 5, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2016, 1001, 1106, NULL, 'Melo Moon', 'melo_moon', 'Twitch', 9, 'approved', 'Live-Set pruefbar.', 'https://youtu.be/demo-melo-live', 'Melo Moon Live Set', 'YouTube', 'available'),
|
||||
(2017, 1001, 1106, NULL, 'Vivi Verse', 'vivi_verse', 'Twitch', 6, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2018, 1001, 1106, NULL, 'Echo Rill', 'echo_rill', 'Twitch', 4, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2019, 1001, 1107, NULL, 'Iris Ink', 'iris_ink', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2020, 1001, 1107, NULL, 'Pia Palette', 'pia_palette', 'Twitch', 6, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2021, 1001, 1107, NULL, 'Theo Thimble', 'theo_thimble', 'Twitch', 3, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2022, 1001, 1108, NULL, 'Nyra Novel', 'nyra_novel', 'Twitch', 9, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2023, 1001, 1108, NULL, 'Kai Myth', 'kai_myth', 'Twitch', 6, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2024, 1001, 1108, NULL, 'Mina Maze', 'mina_maze', 'Twitch', 4, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2025, 1001, 1109, NULL, 'Bibi Beacon', 'bibi_beacon', 'Twitch', 12, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2026, 1001, 1109, NULL, 'Ori Opal', 'ori_opal', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2027, 1001, 1109, NULL, 'Faye Flux', 'faye_flux', 'Twitch', 5, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2028, 1001, 1110, NULL, 'Riku Relay', 'riku_relay', 'Twitch', 10, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2029, 1001, 1110, NULL, 'Nami Node', 'nami_node', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2030, 1001, 1110, NULL, 'Sachi Spark', 'sachi_spark', 'Twitch', 5, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2031, 1001, 1111, NULL, 'Cleo Campfire', 'cleo_campfire', 'Twitch', 11, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2032, 1001, 1111, NULL, 'Juno Jamboree', 'juno_jamboree', 'Twitch', 9, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2033, 1001, 1111, NULL, 'Mika Meetup', 'mika_meetup', 'Twitch', 5, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2034, 1001, 1112, NULL, 'Neon Nori', 'neon_nori', 'Twitch', 9, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2035, 1001, 1112, NULL, 'Slate Sen', 'slate_sen', 'Twitch', 7, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2036, 1001, 1112, NULL, 'Momo Motion', 'momo_motion', 'Twitch', 4, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2037, 1001, 1113, NULL, 'Lio Laughs', 'lio_laughs', 'Twitch', 10, 'approved', 'Clip bereits im Clip-Review.', 'https://clips.twitch.tv/demo-lio-laugh', 'Lio Laughs Original Clip', 'Twitch', 'available'),
|
||||
(2038, 1001, 1113, NULL, 'Puck Prism', 'puck_prism', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2039, 1001, 1113, NULL, 'Tessa Toast', 'tessa_toast', 'Twitch', 5, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2040, 1001, 1114, NULL, 'Vera Volt', 'vera_volt', 'Twitch', 9, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2041, 1001, 1114, NULL, 'Maki Mixer', 'maki_mixer', 'Twitch', 6, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2042, 1001, 1114, NULL, 'Yori Yield', 'yori_yield', 'Twitch', 4, 'pending', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2101, 1000, 1201, NULL, 'Archiv Aster', 'archiv_aster', 'Twitch', 14, 'approved', NULL, 'https://youtu.be/demo-archiv-aster', 'Archiv Aster Winner Reel', 'YouTube', 'available'),
|
||||
(2102, 1000, 1201, NULL, 'Archiv Beryl', 'archiv_beryl', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2103, 1000, 1201, NULL, 'Archiv Coda', 'archiv_coda', 'Twitch', 5, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2104, 1000, 1202, NULL, 'Archiv Drift', 'archiv_drift', 'Twitch', 16, 'approved', NULL, 'https://youtu.be/demo-archiv-drift', 'Archiv Drift Variety Reel', 'YouTube', 'available'),
|
||||
(2105, 1000, 1202, NULL, 'Archiv Elara', 'archiv_elara', 'Twitch', 9, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2106, 1000, 1202, NULL, 'Archiv Finch', 'archiv_finch', 'Twitch', 6, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2107, 1000, 1203, NULL, 'Archiv Lyra', 'archiv_lyra', 'Twitch', 12, 'approved', NULL, 'https://youtu.be/demo-archiv-lyra', 'Archiv Lyra Music Reel', 'YouTube', 'available'),
|
||||
(2108, 1000, 1203, NULL, 'Archiv Muse', 'archiv_muse', 'Twitch', 7, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2109, 1000, 1203, NULL, 'Archiv Nia', 'archiv_nia', 'Twitch', 5, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2110, 1000, 1204, NULL, 'Archiv Poppy', 'archiv_poppy', 'Twitch', 13, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2111, 1000, 1204, NULL, 'Archiv Quartz', 'archiv_quartz', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2112, 1000, 1204, NULL, 'Archiv Rune', 'archiv_rune', 'Twitch', 5, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2113, 1000, 1205, NULL, 'Archiv Sol', 'archiv_sol', 'Twitch', 11, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2114, 1000, 1205, NULL, 'Archiv Tide', 'archiv_tide', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2115, 1000, 1205, NULL, 'Archiv Uma', 'archiv_uma', 'Twitch', 5, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2116, 1000, 1206, NULL, 'Archiv Vesper', 'archiv_vesper', 'Twitch', 15, 'approved', NULL, 'https://clips.twitch.tv/demo-archiv-vesper', 'Archiv Vesper Clip Moment', 'Twitch', 'available'),
|
||||
(2117, 1000, 1206, NULL, 'Archiv Wren', 'archiv_wren', 'Twitch', 8, 'approved', NULL, NULL, NULL, NULL, 'unchecked'),
|
||||
(2118, 1000, 1206, NULL, 'Archiv Yuki', 'archiv_yuki', 'Twitch', 6, 'approved', NULL, NULL, NULL, NULL, 'unchecked');
|
||||
|
||||
INSERT INTO "Nominations" (
|
||||
"Id", "SeasonId", "CategoryId", "CategoryGroupName", "SubmittedByTwitchId", "CandidateId", "StreamerIdentityId", "SuggestedCategoryId",
|
||||
"CandidateText", "StreamUrl", "ResolvedChannel", "ResolvedPlatform", "AvgViewers", "HoursStreamed", "HoursWatched", "PeakViewers", "FollowersGained",
|
||||
"TrackerStatus", "TrackerCheckedAt", "TrackingReviewStatus", "TrackingFlagsJson", "TrackingReviewNote", "TrackingReviewedByTwitchId", "TrackingReviewedAt",
|
||||
"Status", "ReviewNote", "ReviewedByTwitchId", "CreatedAt", "ReviewedAt"
|
||||
)
|
||||
VALUES
|
||||
(4001, 1001, 1101, 'Spotlight', 'viewer_1001', 2001, 3001, NULL, 'Aki Lumina', 'https://twitch.tv/aki_lumina', 'aki_lumina', 'Twitch', 42, 63, 6800, 118, 730, 'resolved', TIMESTAMPTZ '2026-06-18 09:20:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 10:10:00+00', 'approved', 'Wachstum und Streamplan passen.', 'reviewer_demo', TIMESTAMPTZ '2026-06-02 18:30:00+00', TIMESTAMPTZ '2026-06-18 10:10:00+00'),
|
||||
(4002, 1001, 1101, 'Spotlight', 'viewer_1002', 2002, 3002, NULL, 'Mira Orbit', 'https://twitch.tv/mira_orbit', 'mira_orbit', 'Twitch', 38, 58, 5900, 96, 510, 'resolved', TIMESTAMPTZ '2026-06-18 09:24:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 10:15:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-03 20:15:00+00', TIMESTAMPTZ '2026-06-18 10:15:00+00'),
|
||||
(4003, 1001, 1103, 'Spotlight', 'viewer_1003', 2007, 3007, NULL, 'Ember Vail', 'https://twitch.tv/ember_vail', 'ember_vail', 'Twitch', 116, 44, 9100, 420, 1200, 'resolved', TIMESTAMPTZ '2026-06-18 09:30:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 10:22:00+00', 'approved', 'Breakout-Clip verifiziert.', 'reviewer_demo', TIMESTAMPTZ '2026-06-04 14:45:00+00', TIMESTAMPTZ '2026-06-18 10:22:00+00'),
|
||||
(4004, 1001, 1104, 'Content', 'viewer_1004', 2010, 3010, NULL, 'Hana Hertz', 'https://twitch.tv/hana_hertz', 'hana_hertz', 'Twitch', 184, 72, 22100, 360, 840, 'resolved', TIMESTAMPTZ '2026-06-18 09:40:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 10:30:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-05 21:05:00+00', TIMESTAMPTZ '2026-06-18 10:30:00+00'),
|
||||
(4005, 1001, 1105, 'Content', 'viewer_1005', 2013, NULL, NULL, 'Taro Tactics', 'https://twitch.tv/taro_tactics', 'taro_tactics', 'Twitch', 153, 81, 19800, 310, 620, 'resolved', TIMESTAMPTZ '2026-06-18 09:45:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 10:36:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-06 19:25:00+00', TIMESTAMPTZ '2026-06-18 10:36:00+00'),
|
||||
(4006, 1001, 1106, 'Content', 'viewer_1006', 2016, NULL, NULL, 'Melo Moon', 'https://twitch.tv/melo_moon', 'melo_moon', 'Twitch', 88, 35, 7400, 210, 430, 'resolved', TIMESTAMPTZ '2026-06-18 09:48:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 10:41:00+00', 'approved', 'Live-Musik-Set pruefbar.', 'reviewer_demo', TIMESTAMPTZ '2026-06-07 17:55:00+00', TIMESTAMPTZ '2026-06-18 10:41:00+00'),
|
||||
(4007, 1001, 1108, 'Content', 'viewer_1007', 2022, NULL, NULL, 'Nyra Novel', 'https://twitch.tv/nyra_novel', 'nyra_novel', 'Twitch', NULL, NULL, NULL, NULL, NULL, 'no_data', TIMESTAMPTZ '2026-06-18 09:52:00+00', 'needs_review', '[{"key":"no_tracker_data","label":"Keine Tracker-Daten","severity":"medium","description":"TwitchTracker hat keinen belastbaren Summary-Wert geliefert.","requiresManualReview":true,"blocksApproval":false,"adminNoteRequiredOnOverride":false}]', 'Tracker leer, manuelle Lore-Pruefung noetig.', 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:00:00+00', 'pending', 'Lore-Dokumentation nachfordern.', NULL, TIMESTAMPTZ '2026-06-08 18:10:00+00', NULL),
|
||||
(4008, 1001, 1109, 'Engagement', 'viewer_1008', 2025, NULL, NULL, 'Bibi Beacon', 'https://twitch.tv/bibi_beacon', 'bibi_beacon', 'Twitch', 208, 65, 31800, 530, 920, 'resolved', TIMESTAMPTZ '2026-06-18 09:58:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:06:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-09 16:20:00+00', TIMESTAMPTZ '2026-06-18 11:06:00+00'),
|
||||
(4009, 1001, 1110, 'Engagement', 'viewer_1009', 2028, NULL, NULL, 'Riku Relay', 'https://twitch.tv/riku_relay', 'riku_relay', 'Twitch', 177, 54, 24200, 440, 610, 'resolved', TIMESTAMPTZ '2026-06-18 10:02:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:12:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-10 20:40:00+00', TIMESTAMPTZ '2026-06-18 11:12:00+00'),
|
||||
(4010, 1001, 1111, 'Engagement', 'viewer_1010', 2031, NULL, NULL, 'Cleo Campfire', 'https://twitch.tv/cleo_campfire', 'cleo_campfire', 'Twitch', 232, 49, 28700, 620, 1100, 'resolved', TIMESTAMPTZ '2026-06-18 10:08:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:18:00+00', 'approved', 'Community-Event mit Planungsdoku.', 'reviewer_demo', TIMESTAMPTZ '2026-06-11 15:50:00+00', TIMESTAMPTZ '2026-06-18 11:18:00+00'),
|
||||
(4011, 1001, 1112, 'Production', 'viewer_1011', 2034, NULL, NULL, 'Neon Nori', 'https://twitch.tv/neon_nori', 'neon_nori', 'Twitch', 121, 37, 11800, 250, 340, 'resolved', TIMESTAMPTZ '2026-06-18 10:14:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:24:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-12 19:05:00+00', TIMESTAMPTZ '2026-06-18 11:24:00+00'),
|
||||
(4012, 1001, 1113, 'Production', 'viewer_1012', 2037, NULL, NULL, 'Lio Laughs', 'https://twitch.tv/lio_laughs', 'lio_laughs', 'Twitch', 96, 26, 6500, 340, 270, 'resolved', TIMESTAMPTZ '2026-06-18 10:18:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:30:00+00', 'approved', 'Clip pruefbar.', 'reviewer_demo', TIMESTAMPTZ '2026-06-13 22:15:00+00', TIMESTAMPTZ '2026-06-18 11:30:00+00'),
|
||||
(4013, 1001, 1114, 'Production', 'viewer_1013', 2040, NULL, NULL, 'Vera Volt', 'https://twitch.tv/vera_volt', 'vera_volt', 'Twitch', 74, 41, 8200, 190, 390, 'resolved', TIMESTAMPTZ '2026-06-18 10:22:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:36:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-14 13:35:00+00', TIMESTAMPTZ '2026-06-18 11:36:00+00'),
|
||||
(4014, 1001, 1102, 'Spotlight', 'viewer_1014', 2004, 3004, NULL, 'Luna Koi', 'https://twitch.tv/luna_koi', 'luna_koi', 'Twitch', 62, 46, 9100, 155, 510, 'resolved', TIMESTAMPTZ '2026-06-18 10:28:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:42:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-15 18:45:00+00', TIMESTAMPTZ '2026-06-18 11:42:00+00'),
|
||||
(4015, 1001, 1107, 'Content', 'viewer_1015', 2019, NULL, NULL, 'Iris Ink', 'https://twitch.tv/iris_ink', 'iris_ink', 'Twitch', 54, 31, 5200, 130, 260, 'resolved', TIMESTAMPTZ '2026-06-18 10:31:00+00', 'clear', '[]', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:48:00+00', 'approved', NULL, 'reviewer_demo', TIMESTAMPTZ '2026-06-15 20:10:00+00', TIMESTAMPTZ '2026-06-18 11:48:00+00'),
|
||||
(4016, 1001, 1105, 'Content', 'viewer_1016', 2015, NULL, NULL, 'Rin Replay', 'https://youtube.com/@rin_replay', 'rin_replay', 'YouTube', NULL, NULL, NULL, NULL, NULL, 'unsupported_platform', TIMESTAMPTZ '2026-06-18 10:36:00+00', 'needs_review', '[{"key":"unsupported_platform","label":"Plattform nicht unterstuetzt","severity":"medium","description":"Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.","requiresManualReview":true,"blocksApproval":false,"adminNoteRequiredOnOverride":false}]', 'YouTube-Link muss manuell geprueft werden.', NULL, NULL, 'pending', 'Stream-Link auf Twitch anfragen.', NULL, TIMESTAMPTZ '2026-06-16 11:25:00+00', NULL);
|
||||
|
||||
INSERT INTO "VoteBallots" ("Id", "SeasonId", "SubmittedByTwitchId", "Status", "SubmittedAt")
|
||||
VALUES
|
||||
(5001, 1001, 'vote_user_001', 'submitted', TIMESTAMPTZ '2026-06-25 18:10:00+00'),
|
||||
(5002, 1001, 'vote_user_002', 'submitted', TIMESTAMPTZ '2026-06-25 18:16:00+00'),
|
||||
(5003, 1001, 'vote_user_003', 'submitted', TIMESTAMPTZ '2026-06-25 19:05:00+00'),
|
||||
(5004, 1001, 'vote_user_004', 'submitted', TIMESTAMPTZ '2026-06-26 12:45:00+00'),
|
||||
(5005, 1001, 'vote_user_005', 'submitted', TIMESTAMPTZ '2026-06-26 20:30:00+00'),
|
||||
(5006, 1001, 'vote_user_006', 'submitted', TIMESTAMPTZ '2026-06-27 09:20:00+00'),
|
||||
(5007, 1001, 'vote_user_007', 'draft', TIMESTAMPTZ '2026-06-27 14:15:00+00'),
|
||||
(5008, 1001, 'vote_user_008', 'submitted', TIMESTAMPTZ '2026-06-28 21:05:00+00');
|
||||
|
||||
INSERT INTO "VoteEntries" ("Id", "BallotId", "CategoryId", "CandidateId")
|
||||
VALUES
|
||||
(5101, 5001, 1101, 2001), (5102, 5001, 1104, 2010), (5103, 5001, 1109, 2025), (5104, 5001, 1113, 2037),
|
||||
(5105, 5002, 1101, 2002), (5106, 5002, 1105, 2013), (5107, 5002, 1110, 2028), (5108, 5002, 1114, 2040),
|
||||
(5109, 5003, 1102, 2004), (5110, 5003, 1106, 2016), (5111, 5003, 1111, 2031), (5112, 5003, 1112, 2034),
|
||||
(5113, 5004, 1103, 2007), (5114, 5004, 1104, 2011), (5115, 5004, 1108, 2022), (5116, 5004, 1113, 2038),
|
||||
(5117, 5005, 1101, 2001), (5118, 5005, 1105, 2014), (5119, 5005, 1109, 2026), (5120, 5005, 1114, 2041),
|
||||
(5121, 5006, 1102, 2005), (5122, 5006, 1106, 2016), (5123, 5006, 1110, 2029), (5124, 5006, 1112, 2035),
|
||||
(5125, 5007, 1103, 2008), (5126, 5007, 1107, 2019),
|
||||
(5127, 5008, 1101, 2001), (5128, 5008, 1104, 2010), (5129, 5008, 1111, 2032), (5130, 5008, 1113, 2037);
|
||||
|
||||
INSERT INTO "Results" ("Id", "SeasonId", "CategoryId", "CandidateId", "CategoryName")
|
||||
VALUES
|
||||
(6001, 1000, 1201, 2101, 'Archiv Rising Star'),
|
||||
(6002, 1000, 1202, 2104, 'Archiv Variety'),
|
||||
(6003, 1000, 1203, 2107, 'Archiv Music'),
|
||||
(6004, 1000, 1204, 2110, 'Archiv Community'),
|
||||
(6005, 1000, 1205, 2113, 'Archiv Stream Design'),
|
||||
(6006, 1000, 1206, 2116, 'Archiv Clip Moment');
|
||||
|
||||
INSERT INTO "ClipSubmissions" (
|
||||
"Id", "SeasonId", "CategoryId", "CandidateId", "SubmittedByTwitchId", "ClipUrl", "Title", "Creator", "Platform",
|
||||
"Status", "ReviewNote", "ReviewedByTwitchId", "CreatedFromIp", "CreatedAt", "ReviewedAt"
|
||||
)
|
||||
VALUES
|
||||
(7001, 1001, 1103, 2007, 'viewer_2001', 'https://clips.twitch.tv/demo-ember-moment', 'Ember findet den Plot Twist', 'viewer_2001', 'Twitch', 'approved', 'Ton und Kontext passen.', 'clip_reviewer', '127.0.0.1', TIMESTAMPTZ '2026-06-20 18:12:00+00', TIMESTAMPTZ '2026-06-21 09:00:00+00'),
|
||||
(7002, 1001, 1113, 2037, 'viewer_2002', 'https://clips.twitch.tv/demo-lio-laugh', 'Lio verliert komplett die Fassung', 'viewer_2002', 'Twitch', 'approved', NULL, 'clip_reviewer', '127.0.0.1', TIMESTAMPTZ '2026-06-20 20:25:00+00', TIMESTAMPTZ '2026-06-21 09:08:00+00'),
|
||||
(7003, 1001, 1106, 2016, 'viewer_2003', 'https://youtu.be/demo-melo-live', 'Melo Moon acoustic bridge', 'viewer_2003', 'YouTube', 'pending', 'YouTube-Timestamp noch pruefen.', NULL, '127.0.0.1', TIMESTAMPTZ '2026-06-21 11:40:00+00', NULL),
|
||||
(7004, 1001, 1104, 2010, 'viewer_2004', 'https://youtu.be/demo-hana-variety', 'Hana improvised den Chat-Run', 'viewer_2004', 'YouTube', 'approved', NULL, 'clip_reviewer', '127.0.0.1', TIMESTAMPTZ '2026-06-21 15:10:00+00', TIMESTAMPTZ '2026-06-22 08:45:00+00'),
|
||||
(7005, 1001, 1114, 2040, 'viewer_2005', 'https://clips.twitch.tv/demo-vera-setup', 'Vera erklaert ihr neues Setup', 'viewer_2005', 'Twitch', 'pending', NULL, NULL, '127.0.0.1', TIMESTAMPTZ '2026-06-22 19:55:00+00', NULL),
|
||||
(7006, 1001, 1109, 2025, 'viewer_2006', 'https://clips.twitch.tv/demo-bibi-chat', 'Bibi laesst Chat entscheiden', 'viewer_2006', 'Twitch', 'rejected', 'Zu wenig Kontext fuer Award-Clip.', 'clip_reviewer', '127.0.0.1', TIMESTAMPTZ '2026-06-23 12:30:00+00', TIMESTAMPTZ '2026-06-23 14:05:00+00'),
|
||||
(7007, 1000, 1206, 2116, 'archiv_viewer_1', 'https://clips.twitch.tv/demo-archiv-vesper', 'Archiv Vesper Finale Clip', 'archiv_viewer_1', 'Twitch', 'approved', NULL, 'clip_reviewer', '127.0.0.1', TIMESTAMPTZ '2025-06-10 19:00:00+00', TIMESTAMPTZ '2025-06-11 09:00:00+00');
|
||||
|
||||
INSERT INTO "ShowactApplications" (
|
||||
"Id", "SeasonId", "ArtistName", "ContactEmail", "ContactDiscord", "PlatformUrl", "PerformanceType", "Description",
|
||||
"TechnicalNotes", "ReferenceUrl", "FieldResponsesJson", "Status", "ReviewNote", "ReviewedByTwitchId", "CreatedFromIp", "UserAgent", "CreatedAt", "ReviewedAt"
|
||||
)
|
||||
VALUES
|
||||
(8001, 1001, 'Melo Moon', 'melo@example.invalid', 'melo_moon', 'https://twitch.tv/melo_moon', 'Live-Gesang', 'Akustisches Opening-Medley mit zwei kurzen Songs.', 'Benoetigt Instrumentalspur und Monitoring.', 'https://youtu.be/demo-melo-live', '{"performanceLength":"6 Minuten","contentRating":"family friendly"}', 'approved', 'Passt als Opener.', 'show_reviewer', '127.0.0.1', 'DemoBrowser/1.0', TIMESTAMPTZ '2026-06-19 13:10:00+00', TIMESTAMPTZ '2026-06-20 10:00:00+00'),
|
||||
(8002, 1001, 'Neon Nori', 'nori@example.invalid', 'neon_nori', 'https://twitch.tv/neon_nori', 'Visual Interlude', 'Kurze Motion-Overlay-Performance zwischen zwei Award-Bloecken.', 'OBS-Szene mit WebM-Loop, kein Mikro.', 'https://youtu.be/demo-nori-motion', '{"performanceLength":"3 Minuten","contentRating":"keine Hinweise"}', 'pending', NULL, NULL, '127.0.0.1', 'DemoBrowser/1.0', TIMESTAMPTZ '2026-06-20 16:40:00+00', NULL),
|
||||
(8003, 1001, 'Cleo Campfire', 'cleo@example.invalid', 'cleo_campfire', 'https://twitch.tv/cleo_campfire', 'Community Skit', 'Kurzer Call-and-response Sketch mit Chat-Kommandos.', 'Braucht Chat-Overlay-Freigabe.', 'https://youtu.be/demo-cleo-skit', '{"performanceLength":"5 Minuten","contentRating":"leichte Improvisation"}', 'pending', 'Technische Details klaeren.', NULL, '127.0.0.1', 'DemoBrowser/1.0', TIMESTAMPTZ '2026-06-22 12:05:00+00', NULL),
|
||||
(8004, 1000, 'Archiv Lyra', 'lyra@example.invalid', 'archiv_lyra', 'https://twitch.tv/archiv_lyra', 'Archiv Musik', 'Gewinner-Showcase aus dem Vorjahr.', 'VOD bereits vorhanden.', 'https://youtu.be/demo-archiv-lyra', '{"performanceLength":"4 Minuten","contentRating":"family friendly"}', 'approved', 'Archiv-Showact.', 'show_reviewer', '127.0.0.1', 'DemoBrowser/1.0', TIMESTAMPTZ '2025-06-01 12:05:00+00', TIMESTAMPTZ '2025-06-02 09:30:00+00');
|
||||
|
||||
INSERT INTO "Sponsors" (
|
||||
"Id", "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt"
|
||||
)
|
||||
VALUES
|
||||
(9001, 1001, 'CloudBeacon Hosting', 'https://example.invalid/cloudbeacon', '/demo/sponsors/cloudbeacon-hosting.svg', 'Server- und Bot-Hosting fuer Community-Projekte.', 'Main Partner', 10, TRUE, TIMESTAMPTZ '2026-06-01 10:00:00+00', NULL),
|
||||
(9002, 1001, 'NekoPixel Energy', 'https://example.invalid/nekopixel', '/demo/sponsors/nekopixel-energy.svg', 'Fiktiver Energy-Drink fuer lange Stream-Naechte.', 'Gold Partner', 20, TRUE, TIMESTAMPTZ '2026-06-01 10:05:00+00', NULL),
|
||||
(9003, 1001, 'PrismLoop Audio', 'https://example.invalid/prismloop', '/demo/sponsors/prismloop-audio.svg', 'Audio-Tools und Soundpacks fuer Creator:innen.', 'Gold Partner', 30, TRUE, TIMESTAMPTZ '2026-06-01 10:10:00+00', NULL),
|
||||
(9004, 1001, 'HoshiForge Studio', 'https://example.invalid/hoshiforge', '/demo/sponsors/hoshiforge-studio.svg', 'Branding, Overlays und kleine Motion-Pakete.', 'Community Partner', 40, TRUE, TIMESTAMPTZ '2026-06-01 10:15:00+00', NULL),
|
||||
(9005, 1001, 'ChibiCanvas Market', 'https://example.invalid/chibicanvas', '/demo/sponsors/chibicanvas-market.svg', 'Asset-Marktplatz fuer Panels, Emotes und Stream-Grafiken.', 'Community Partner', 50, TRUE, TIMESTAMPTZ '2026-06-01 10:20:00+00', NULL);
|
||||
|
||||
INSERT INTO "RiskFlags" (
|
||||
"Id", "SeasonId", "TwitchUserId", "Source", "Type", "Severity", "Status", "Summary", "CreatedFromIp", "UserAgent",
|
||||
"MetadataJson", "ReviewNote", "ReviewedByTwitchId", "CreatedAt", "ReviewedAt"
|
||||
)
|
||||
VALUES
|
||||
(10001, 1001, 'viewer_1016', 'tracking-review', 'unsupported_platform', 'medium', 'open', 'Nominierung nutzt einen YouTube-Link und benoetigt manuellen Tracker-Review.', '127.0.0.1', 'DemoBrowser/1.0', '{"seasonId":1001,"nominationId":4016}', NULL, NULL, TIMESTAMPTZ '2026-06-18 10:40:00+00', NULL),
|
||||
(10002, 1001, 'viewer_1007', 'tracking-review', 'no_tracker_data', 'medium', 'open', 'TwitchTracker lieferte keine belastbaren Daten fuer Nyra Novel.', '127.0.0.1', 'DemoBrowser/1.0', '{"seasonId":1001,"nominationId":4007}', 'Manuelle Lore-Pruefung vor Finale.', 'reviewer_demo', TIMESTAMPTZ '2026-06-18 11:02:00+00', TIMESTAMPTZ '2026-06-18 11:08:00+00'),
|
||||
(10003, 1001, 'vote_user_007', 'voting', 'draft_ballot', 'low', 'resolved', 'Draft-Ballot ohne Submission, sichtbar fuer Dashboard-Randfall.', '127.0.0.1', 'DemoBrowser/1.0', '{"seasonId":1001,"ballotId":5007}', 'Nur Demo-Randfall.', 'reviewer_demo', TIMESTAMPTZ '2026-06-27 14:20:00+00', TIMESTAMPTZ '2026-06-27 15:00:00+00'),
|
||||
(10004, 1000, 'archiv_viewer_1', 'archive-cleanup', 'archived_clip', 'low', 'resolved', 'Archiv-Clip wurde fuer Gewinnerarchiv geprueft.', '127.0.0.1', 'DemoBrowser/1.0', '{"seasonId":1000,"clipId":7007}', 'Archiv okay.', 'reviewer_demo', TIMESTAMPTZ '2025-06-11 09:10:00+00', TIMESTAMPTZ '2025-06-11 09:25:00+00');
|
||||
|
||||
SELECT setval(pg_get_serial_sequence('"Seasons"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Seasons"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"Categories"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Categories"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"StreamerIdentities"', 'Id'), COALESCE((SELECT MAX("Id") FROM "StreamerIdentities"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"Candidates"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Candidates"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"Nominations"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Nominations"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"VoteBallots"', 'Id'), COALESCE((SELECT MAX("Id") FROM "VoteBallots"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"VoteEntries"', 'Id'), COALESCE((SELECT MAX("Id") FROM "VoteEntries"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"Results"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Results"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"ClipSubmissions"', 'Id'), COALESCE((SELECT MAX("Id") FROM "ClipSubmissions"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"ShowactApplications"', 'Id'), COALESCE((SELECT MAX("Id") FROM "ShowactApplications"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"Sponsors"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Sponsors"), 1));
|
||||
SELECT setval(pg_get_serial_sequence('"RiskFlags"', 'Id'), COALESCE((SELECT MAX("Id") FROM "RiskFlags"), 1));
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DELETE FROM "RiskFlags" WHERE "SeasonId" IN (1000, 1001);
|
||||
DELETE FROM "Seasons" WHERE "Id" IN (1000, 1001) AND "IsDemo" = TRUE;
|
||||
DELETE FROM "StreamerIdentities" WHERE "Id" BETWEEN 3001 AND 3010;
|
||||
"""
|
||||
);
|
||||
}
|
||||
}
|
||||
+557
-468
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user