Compare commits
23 Commits
494eba5edd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23fddbd976 | |||
| 353eb97563 | |||
| 33ba00a682 | |||
| 1191abc914 | |||
| 76ac60d3fd | |||
| 0803a0124b | |||
| 6b3b0360e7 | |||
| f323e5a82c | |||
| 09dc1de451 | |||
| 049db2e0e0 | |||
| 65c9815baa | |||
| 3794b7f29e | |||
| 709a024820 | |||
| e497f19652 | |||
| c9fd3e47cb | |||
| 1a74c03621 | |||
| 441ef2b850 | |||
| c466348d18 | |||
| 1ab26ec2fd | |||
| b53c7fb736 | |||
| 4b2c5fa15d | |||
| fc5c13a4fd | |||
| 18b61bed52 |
@@ -171,9 +171,40 @@ jobs:
|
|||||||
alpine:latest \
|
alpine:latest \
|
||||||
sh -lc '
|
sh -lc '
|
||||||
test -d /app/frontend
|
test -d /app/frontend
|
||||||
|
mkdir -p /app/frontend/public
|
||||||
printf "VITE_BUILD_VERSION=%s\nVITE_BUILD_DATE=%s\n" "$BUILD_VERSION" "$BUILD_DATE" > /app/frontend/.env.production
|
printf "VITE_BUILD_VERSION=%s\nVITE_BUILD_DATE=%s\n" "$BUILD_VERSION" "$BUILD_DATE" > /app/frontend/.env.production
|
||||||
|
printf "{\n \"version\": \"%s\",\n \"buildDate\": \"%s\"\n}\n" "$BUILD_VERSION" "$BUILD_DATE" > /app/frontend/public/build-meta.json
|
||||||
echo "Frontend build metadata:"
|
echo "Frontend build metadata:"
|
||||||
cat /app/frontend/.env.production
|
cat /app/frontend/.env.production
|
||||||
|
cat /app/frontend/public/build-meta.json
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Prepare deploy host disk headroom
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm \
|
||||||
|
-v "${DEPLOY_PATH}:/workspace/vtube-awards" \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-w /workspace/vtube-awards \
|
||||||
|
docker:cli \
|
||||||
|
sh -lc '
|
||||||
|
set -e
|
||||||
|
echo "Disk before cleanup:"
|
||||||
|
df -h .
|
||||||
|
|
||||||
|
mkdir -p backups
|
||||||
|
old_backups="$(ls -1t backups/predeploy-*.dump 2>/dev/null | tail -n +4 || true)"
|
||||||
|
if [ -n "$old_backups" ]; then
|
||||||
|
echo "$old_backups" | while IFS= read -r backup; do
|
||||||
|
[ -n "$backup" ] && rm -f "$backup"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker builder prune -af || true
|
||||||
|
docker image prune -af || true
|
||||||
|
|
||||||
|
echo "Disk after cleanup:"
|
||||||
|
df -h .
|
||||||
'
|
'
|
||||||
|
|
||||||
- name: Build Docker images
|
- name: Build Docker images
|
||||||
@@ -280,9 +311,11 @@ jobs:
|
|||||||
css_asset="$(printf "%s" "$index" | grep -Eo '/assets/index-[^"]+\.css' | head -n 1)"
|
css_asset="$(printf "%s" "$index" | grep -Eo '/assets/index-[^"]+\.css' | head -n 1)"
|
||||||
test -n "$js_asset"
|
test -n "$js_asset"
|
||||||
test -n "$css_asset"
|
test -n "$css_asset"
|
||||||
js_body="$(curl -fsS --max-time 20 "${LIVE_URL}${js_asset}")"
|
curl -fsS --max-time 20 "${LIVE_URL}${js_asset}" >/dev/null
|
||||||
printf "%s" "$js_body" | grep -Fq "${{ steps.meta.outputs.build_version }}"
|
|
||||||
curl -fsS --max-time 20 "${LIVE_URL}${css_asset}" >/dev/null
|
curl -fsS --max-time 20 "${LIVE_URL}${css_asset}" >/dev/null
|
||||||
|
meta_body="$(curl -fsS --max-time 15 "${LIVE_URL}/build-meta.json")"
|
||||||
|
echo "$meta_body"
|
||||||
|
echo "$meta_body" | grep -Fq '"version": "${{ steps.meta.outputs.build_version }}"'
|
||||||
echo "Frontend assets served: ${js_asset}, ${css_asset}"
|
echo "Frontend assets served: ${js_asset}, ${css_asset}"
|
||||||
echo "Frontend version verified: ${{ steps.meta.outputs.build_version }}"
|
echo "Frontend version verified: ${{ steps.meta.outputs.build_version }}"
|
||||||
|
|
||||||
|
|||||||
+31
@@ -3,7 +3,38 @@ frontend/dist/
|
|||||||
Backend/bin/
|
Backend/bin/
|
||||||
Backend/obj/
|
Backend/obj/
|
||||||
.DS_Store
|
.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
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
*.docx
|
*.docx
|
||||||
|
|
||||||
# Local Claude Code config (settings, preview launch configs)
|
# 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
|
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__0=http://localhost:5173
|
||||||
Frontend__AllowedOrigins__1=http://127.0.0.1:5173
|
Frontend__AllowedOrigins__1=http://127.0.0.1:5173
|
||||||
VTSA_SEED_MODE=demo
|
|
||||||
VTSA_DEMO_LOGIN_ENABLED=true
|
VTSA_DEMO_LOGIN_ENABLED=true
|
||||||
VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin
|
VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin
|
||||||
VTSA_DEMO_ADMIN_EMAIL=admin@example.local
|
VTSA_DEMO_ADMIN_EMAIL=admin@example.local
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Domain;
|
using Backend.Domain;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
namespace Backend.Common;
|
namespace Backend.Common;
|
||||||
|
|
||||||
public static class SeasonMappings
|
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(
|
public static bool IsSeasonScheduleValid(
|
||||||
DateOnly nominationStartsAt,
|
DateOnly nominationStartsAt,
|
||||||
DateOnly nominationEndsAt,
|
DateOnly nominationEndsAt,
|
||||||
@@ -84,12 +92,78 @@ public static class SeasonMappings
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static (string Platform, string Slug) InferProfileMetadataFromUrl(string? value, string fallbackName = "")
|
||||||
|
{
|
||||||
|
var trimmed = value?.Trim() ?? string.Empty;
|
||||||
|
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return ("Profil", fallbackName.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = uri.Host.Trim().ToLowerInvariant();
|
||||||
|
var segments = uri.AbsolutePath
|
||||||
|
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
var platform = host switch
|
||||||
|
{
|
||||||
|
var item when item.Contains("twitch.tv", StringComparison.Ordinal) => "Twitch",
|
||||||
|
var item when item.Contains("youtube.com", StringComparison.Ordinal) || item.Contains("youtu.be", StringComparison.Ordinal) => "YouTube",
|
||||||
|
var item when item.Contains("x.com", StringComparison.Ordinal) || item.Contains("twitter.com", StringComparison.Ordinal) => "X",
|
||||||
|
var item when item.Contains("instagram.com", StringComparison.Ordinal) => "Instagram",
|
||||||
|
var item when item.Contains("discord.gg", StringComparison.Ordinal) || item.Contains("discord.com", StringComparison.Ordinal) => "Discord",
|
||||||
|
var item when item.Contains("kick.com", StringComparison.Ordinal) => "Kick",
|
||||||
|
var item when item.Contains("cake.gg", StringComparison.Ordinal) => "Cake",
|
||||||
|
_ => "Profil",
|
||||||
|
};
|
||||||
|
|
||||||
|
var slug = segments.LastOrDefault() ?? string.Empty;
|
||||||
|
if (string.Equals(platform, "YouTube", StringComparison.Ordinal) && segments.Length > 0)
|
||||||
|
{
|
||||||
|
slug = segments.FirstOrDefault(segment => segment.StartsWith('@')) ?? slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
slug = Uri.UnescapeDataString(slug).Trim().Trim('/');
|
||||||
|
if (slug.StartsWith('@') && !string.Equals(platform, "YouTube", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
slug = slug[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
{
|
||||||
|
slug = fallbackName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (platform, slug);
|
||||||
|
}
|
||||||
|
|
||||||
public static string NormalizeSeasonStreamUrl(string? value)
|
public static string NormalizeSeasonStreamUrl(string? value)
|
||||||
{
|
{
|
||||||
var trimmed = value?.Trim() ?? string.Empty;
|
var trimmed = value?.Trim() ?? string.Empty;
|
||||||
return string.IsNullOrWhiteSpace(trimmed) ? "https://twitch.tv/jayuhime" : trimmed;
|
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)
|
public static string NormalizePhaseKey(string? currentPhase)
|
||||||
{
|
{
|
||||||
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
@@ -183,6 +257,7 @@ public static class SeasonMappings
|
|||||||
[
|
[
|
||||||
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
|
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
|
||||||
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record AdminArchivedWinnerItemDto(
|
||||||
|
int Id,
|
||||||
|
int Year,
|
||||||
|
string Category,
|
||||||
|
string Subcategory,
|
||||||
|
string WinnerName,
|
||||||
|
string WinnerUrl,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? UpdatedAt);
|
||||||
|
|
||||||
|
public sealed record UpsertArchivedWinnerRequest(
|
||||||
|
int Year,
|
||||||
|
string Category,
|
||||||
|
string Subcategory,
|
||||||
|
string WinnerName,
|
||||||
|
string WinnerUrl);
|
||||||
@@ -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 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(
|
public sealed record AdminDashboardResponse(
|
||||||
|
int SeasonId,
|
||||||
|
int Year,
|
||||||
|
string SeasonName,
|
||||||
|
bool IsCurrent,
|
||||||
IEnumerable<AdminMetricDto> Metrics,
|
IEnumerable<AdminMetricDto> Metrics,
|
||||||
IEnumerable<AdminActivityDto> Activities,
|
IEnumerable<AdminActivityDto> Activities,
|
||||||
IEnumerable<AdminTopCategoryDto> TopCategories,
|
IEnumerable<AdminTopCategoryDto> TopCategories,
|
||||||
|
|||||||
@@ -55,11 +55,27 @@ public sealed record AdminAuditEntriesResponse(
|
|||||||
|
|
||||||
public sealed record AdminNominationReviewItemDto(
|
public sealed record AdminNominationReviewItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
int CategoryId,
|
int? CategoryId,
|
||||||
|
string CategoryGroupName,
|
||||||
string CategoryName,
|
string CategoryName,
|
||||||
string SubmittedByTwitchId,
|
string SubmittedByTwitchId,
|
||||||
string CandidateText,
|
string CandidateText,
|
||||||
string? StreamUrl,
|
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,
|
string Status,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
int? CandidateId,
|
int? CandidateId,
|
||||||
@@ -68,6 +84,32 @@ public sealed record AdminNominationReviewItemDto(
|
|||||||
string? ReviewedByTwitchId,
|
string? ReviewedByTwitchId,
|
||||||
DateTimeOffset? ReviewedAt);
|
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(
|
public sealed record AdminClipSubmissionItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
int? CategoryId,
|
int? CategoryId,
|
||||||
@@ -87,10 +129,25 @@ public sealed record ApproveNominationRequest(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? ChannelSlug,
|
string? ChannelSlug,
|
||||||
string? Platform,
|
string? Platform,
|
||||||
|
int? CategoryId,
|
||||||
string? ReviewNote);
|
string? ReviewNote);
|
||||||
|
|
||||||
public sealed record RejectNominationRequest(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(
|
public sealed record UpdateClipStatusRequest(
|
||||||
string Status,
|
string Status,
|
||||||
string? ReviewNote);
|
string? ReviewNote);
|
||||||
@@ -116,3 +173,36 @@ public sealed record AdminRiskRuleDto(
|
|||||||
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
|
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
|
||||||
|
|
||||||
public sealed record UpdateRiskRulesRequest(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 Name,
|
||||||
string CurrentPhase,
|
string CurrentPhase,
|
||||||
bool IsCurrent,
|
bool IsCurrent,
|
||||||
int CategoryCount);
|
bool IsDemo,
|
||||||
|
int CategoryCount,
|
||||||
|
DateTimeOffset? WinnersPublishedAt,
|
||||||
|
string? WinnersPublishedByTwitchId);
|
||||||
|
|
||||||
public sealed record AdminCategoryItemDto(
|
public sealed record AdminCategoryItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
@@ -16,29 +19,99 @@ public sealed record AdminCategoryItemDto(
|
|||||||
string Description,
|
string Description,
|
||||||
int SortOrder,
|
int SortOrder,
|
||||||
int MaxNomineesPerUser,
|
int MaxNomineesPerUser,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax,
|
||||||
int CandidateCount);
|
int CandidateCount);
|
||||||
|
|
||||||
|
public sealed record AdminSubcategoryTemplateDto(
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
int SortOrder,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax);
|
||||||
|
|
||||||
public sealed record AdminCandidateItemDto(
|
public sealed record AdminCandidateItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
int CategoryId,
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
string ChannelSlug,
|
||||||
string Platform);
|
string Platform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int Votes,
|
||||||
|
int NominationTally,
|
||||||
|
string AcceptanceStatus,
|
||||||
|
string? AcceptanceNote,
|
||||||
|
string? ClipCompilationUrl,
|
||||||
|
string? ClipCompilationTitle,
|
||||||
|
string? ClipCompilationPlatform,
|
||||||
|
string ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record AdminAwardResultItemDto(
|
public sealed record AdminAwardResultItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
int CategoryId,
|
int CategoryId,
|
||||||
string CategoryName,
|
string CategoryName,
|
||||||
int CandidateId,
|
int CandidateId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
string CandidateDisplayName,
|
string CandidateDisplayName,
|
||||||
string CandidateChannelSlug,
|
string CandidateChannelSlug,
|
||||||
string CandidatePlatform);
|
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(
|
public sealed record AdminSeasonDetailResponse(
|
||||||
int Id,
|
int Id,
|
||||||
int Year,
|
int Year,
|
||||||
string Name,
|
string Name,
|
||||||
string ShowStreamUrl,
|
bool IsDemo,
|
||||||
string CurrentPhase,
|
string CurrentPhase,
|
||||||
bool IsCurrent,
|
bool IsCurrent,
|
||||||
bool IsCommunityOnly,
|
bool IsCommunityOnly,
|
||||||
@@ -50,17 +123,23 @@ public sealed record AdminSeasonDetailResponse(
|
|||||||
DateOnly ReviewEndsAt,
|
DateOnly ReviewEndsAt,
|
||||||
DateOnly ShowDate,
|
DateOnly ShowDate,
|
||||||
TimeOnly ShowStartsAt,
|
TimeOnly ShowStartsAt,
|
||||||
|
DateTimeOffset? WinnersPublishedAt,
|
||||||
|
string? WinnersPublishedByTwitchId,
|
||||||
|
IEnumerable<AdminSubcategoryTemplateDto> SubcategoryTemplates,
|
||||||
IEnumerable<AdminCategoryItemDto> Categories,
|
IEnumerable<AdminCategoryItemDto> Categories,
|
||||||
IEnumerable<AdminCandidateItemDto> Candidates,
|
IEnumerable<AdminCandidateItemDto> Candidates,
|
||||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
||||||
|
IEnumerable<AdminNominationReviewGroupDto> PendingNominationGroups,
|
||||||
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
||||||
|
string TrackingReviewNotes,
|
||||||
|
bool ShowTrackingReviewNotes,
|
||||||
IEnumerable<AdminAwardResultItemDto> Results,
|
IEnumerable<AdminAwardResultItemDto> Results,
|
||||||
|
AdminVotingWorkspaceDto VotingWorkspace,
|
||||||
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
||||||
|
|
||||||
public sealed record CreateSeasonRequest(
|
public sealed record CreateSeasonRequest(
|
||||||
int Year,
|
int Year,
|
||||||
string Name,
|
string Name,
|
||||||
string ShowStreamUrl,
|
|
||||||
string CurrentPhase,
|
string CurrentPhase,
|
||||||
bool IsCurrent,
|
bool IsCurrent,
|
||||||
bool IsCommunityOnly,
|
bool IsCommunityOnly,
|
||||||
@@ -77,7 +156,6 @@ public sealed record CreateSeasonRequest(
|
|||||||
public sealed record UpdateSeasonRequest(
|
public sealed record UpdateSeasonRequest(
|
||||||
int Year,
|
int Year,
|
||||||
string Name,
|
string Name,
|
||||||
string ShowStreamUrl,
|
|
||||||
string CurrentPhase,
|
string CurrentPhase,
|
||||||
bool IsCurrent,
|
bool IsCurrent,
|
||||||
bool IsCommunityOnly,
|
bool IsCommunityOnly,
|
||||||
@@ -96,13 +174,30 @@ public sealed record UpsertCategoryRequest(
|
|||||||
string Slug,
|
string Slug,
|
||||||
string Description,
|
string Description,
|
||||||
int SortOrder,
|
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);
|
int MaxNomineesPerUser);
|
||||||
|
|
||||||
public sealed record UpsertCandidateRequest(
|
public sealed record UpsertCandidateRequest(
|
||||||
int CategoryId,
|
int CategoryId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
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(
|
public sealed record SetAwardResultRequest(
|
||||||
int CategoryId,
|
int CategoryId,
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ namespace Backend.Contracts;
|
|||||||
public sealed record AdminSiteSettingsResponse(
|
public sealed record AdminSiteSettingsResponse(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
|
string HostImageUrl,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
string PrivacyEmail,
|
string PrivacyEmail,
|
||||||
string PrivacyPolicyContent,
|
string PrivacyPolicyContent,
|
||||||
string? PrivacyPolicyUpdatedBy,
|
string? PrivacyPolicyUpdatedBy,
|
||||||
@@ -14,13 +18,35 @@ public sealed record AdminSiteSettingsResponse(
|
|||||||
string ContactContent,
|
string ContactContent,
|
||||||
string SponsorsUrl,
|
string SponsorsUrl,
|
||||||
string SponsorsContent,
|
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<PublicSocialLinkDto> SocialLinks,
|
||||||
IEnumerable<FaqItemDto> Faq);
|
IEnumerable<FaqItemDto> Faq,
|
||||||
|
string ShowactFormSchemaJson);
|
||||||
|
|
||||||
public sealed record UpdateSiteSettingsRequest(
|
public sealed record UpdateSiteSettingsRequest(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
string PrivacyEmail,
|
string PrivacyEmail,
|
||||||
string PrivacyPolicyContent,
|
string PrivacyPolicyContent,
|
||||||
string ImprintUrl,
|
string ImprintUrl,
|
||||||
@@ -29,8 +55,27 @@ public sealed record UpdateSiteSettingsRequest(
|
|||||||
string ContactContent,
|
string ContactContent,
|
||||||
string SponsorsUrl,
|
string SponsorsUrl,
|
||||||
string SponsorsContent,
|
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,
|
PublicSocialLinkDto[] SocialLinks,
|
||||||
FaqItemDto[] Faq);
|
FaqItemDto[] Faq,
|
||||||
|
string? ShowactFormSchemaJson = null);
|
||||||
|
|
||||||
public sealed record AdminOperationalSettingsResponse(
|
public sealed record AdminOperationalSettingsResponse(
|
||||||
bool DemoLoginManagedByDatabase,
|
bool DemoLoginManagedByDatabase,
|
||||||
@@ -45,10 +90,34 @@ public sealed record AdminOperationalSettingsResponse(
|
|||||||
bool TwitchClientSecretSet,
|
bool TwitchClientSecretSet,
|
||||||
string TwitchRedirectUri,
|
string TwitchRedirectUri,
|
||||||
string TwitchScope,
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
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(
|
public sealed record UpdateOperationalSettingsRequest(
|
||||||
bool DemoLoginEnabled,
|
bool DemoLoginEnabled,
|
||||||
string DemoLoginEmail,
|
string DemoLoginEmail,
|
||||||
@@ -59,6 +128,71 @@ public sealed record UpdateOperationalSettingsRequest(
|
|||||||
string? TwitchClientSecret,
|
string? TwitchClientSecret,
|
||||||
string TwitchRedirectUri,
|
string TwitchRedirectUri,
|
||||||
string TwitchScope,
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
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 Key,
|
||||||
string Label,
|
string Label,
|
||||||
string Description,
|
string Description,
|
||||||
|
string GroupLabel,
|
||||||
string MenuPath,
|
string MenuPath,
|
||||||
bool ReadOnlySupported);
|
bool ReadOnlySupported);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed record AuthSessionDto(
|
|||||||
string DisplayName,
|
string DisplayName,
|
||||||
string Role,
|
string Role,
|
||||||
IEnumerable<string> PermissionKeys,
|
IEnumerable<string> PermissionKeys,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MustChangePassword = false,
|
bool MustChangePassword = false,
|
||||||
string? TeamLogin = null,
|
string? TeamLogin = null,
|
||||||
string? BoundTwitchUserId = 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(
|
public sealed record WinnerPreviewDto(
|
||||||
int Year,
|
int Year,
|
||||||
|
string CategoryGroup,
|
||||||
string Category,
|
string Category,
|
||||||
string WinnerName,
|
string WinnerName,
|
||||||
string WinnerSlug,
|
string WinnerSlug,
|
||||||
string WinnerPlatform,
|
string WinnerPlatform,
|
||||||
string WinnerUrl);
|
string WinnerUrl,
|
||||||
|
string? ClipUrl,
|
||||||
|
string? ClipTitle,
|
||||||
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record ArchiveYearDto(
|
public sealed record ArchiveYearDto(
|
||||||
int Year,
|
int Year,
|
||||||
@@ -42,12 +47,35 @@ public sealed record FooterLinkDto(
|
|||||||
string Url,
|
string Url,
|
||||||
string Content);
|
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(
|
public sealed record PublicSiteContentDto(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
|
string HostImageUrl,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
string PrivacyEmail,
|
string PrivacyEmail,
|
||||||
string PrivacyPolicyContent,
|
string PrivacyPolicyContent,
|
||||||
|
string AwardsSectionTitle,
|
||||||
|
string AwardsSectionDescription,
|
||||||
|
string SubcategoriesSectionTitle,
|
||||||
|
string SubcategoriesSectionDescription,
|
||||||
|
PublicStreamBannerContentDto StreamBanner,
|
||||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||||
IEnumerable<FooterLinkDto> FooterLinks);
|
IEnumerable<FooterLinkDto> FooterLinks);
|
||||||
|
|
||||||
@@ -57,13 +85,23 @@ public sealed record PublicSiteStatusResponse(
|
|||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
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(
|
public sealed record OverviewResponse(
|
||||||
int SeasonId,
|
int SeasonId,
|
||||||
int Year,
|
int Year,
|
||||||
string Title,
|
string Title,
|
||||||
DateOnly ShowDate,
|
DateOnly ShowDate,
|
||||||
TimeOnly ShowStartsAt,
|
TimeOnly ShowStartsAt,
|
||||||
string ShowStreamUrl,
|
|
||||||
string CurrentPhase,
|
string CurrentPhase,
|
||||||
bool IsCommunityOnly,
|
bool IsCommunityOnly,
|
||||||
string LoginProvider,
|
string LoginProvider,
|
||||||
@@ -72,4 +110,5 @@ public sealed record OverviewResponse(
|
|||||||
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
||||||
IEnumerable<ArchiveYearDto> ArchiveYears,
|
IEnumerable<ArchiveYearDto> ArchiveYears,
|
||||||
PublicSiteContentDto SiteContent,
|
PublicSiteContentDto SiteContent,
|
||||||
|
PublicFeatureFlagsDto FeatureFlags,
|
||||||
IEnumerable<FaqItemDto> Faq);
|
IEnumerable<FaqItemDto> Faq);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ public sealed record CandidateSummaryDto(
|
|||||||
int Id,
|
int Id,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
string ChannelSlug,
|
||||||
|
string ChannelUrl,
|
||||||
string Platform,
|
string Platform,
|
||||||
string? ClipUrl,
|
string? ClipUrl,
|
||||||
string? ClipTitle,
|
string? ClipTitle,
|
||||||
string? ClipPlatform);
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record PublicCategoryDetailDto(
|
public sealed record PublicCategoryDetailDto(
|
||||||
int Id,
|
int Id,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
namespace Backend.Contracts;
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
public sealed record UserNominationStateDto(
|
public sealed record UserNominationStateDto(
|
||||||
int CategoryId,
|
int? CategoryId,
|
||||||
|
string CategoryGroupName,
|
||||||
string[] Nominees);
|
string[] Nominees);
|
||||||
|
|
||||||
public sealed record UserVoteStateDto(
|
public sealed record UserVoteStateDto(
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
namespace Backend.Contracts;
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
public sealed record WinnerArchiveItemDto(
|
public sealed record WinnerArchiveItemDto(
|
||||||
|
string CategoryGroup,
|
||||||
string Category,
|
string Category,
|
||||||
string WinnerName,
|
string WinnerName,
|
||||||
string WinnerSlug,
|
string WinnerSlug,
|
||||||
string WinnerPlatform,
|
string WinnerPlatform,
|
||||||
string WinnerUrl);
|
string WinnerUrl,
|
||||||
|
string? ClipUrl,
|
||||||
|
string? ClipTitle,
|
||||||
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record WinnerArchiveResponse(
|
public sealed record WinnerArchiveResponse(
|
||||||
int Year,
|
int Year,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ public sealed record NominationEntryRequest(
|
|||||||
|
|
||||||
public sealed record CreateNominationRequest(
|
public sealed record CreateNominationRequest(
|
||||||
int Year,
|
int Year,
|
||||||
int CategoryId,
|
int? CategoryId,
|
||||||
|
string? CategoryGroupName,
|
||||||
string TwitchUserId,
|
string TwitchUserId,
|
||||||
string[]? Nominees,
|
string[]? Nominees,
|
||||||
NominationEntryRequest[]? Nominations);
|
NominationEntryRequest[]? Nominations);
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
public DbSet<Season> Seasons => Set<Season>();
|
public DbSet<Season> Seasons => Set<Season>();
|
||||||
public DbSet<Category> Categories => Set<Category>();
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
public DbSet<Candidate> Candidates => Set<Candidate>();
|
public DbSet<Candidate> Candidates => Set<Candidate>();
|
||||||
|
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
|
||||||
public DbSet<AwardResult> Results => Set<AwardResult>();
|
public DbSet<AwardResult> Results => Set<AwardResult>();
|
||||||
|
public DbSet<ArchivedWinner> ArchivedWinners => Set<ArchivedWinner>();
|
||||||
public DbSet<Nomination> Nominations => Set<Nomination>();
|
public DbSet<Nomination> Nominations => Set<Nomination>();
|
||||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||||
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
|
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
|
||||||
@@ -16,6 +18,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||||
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
|
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<SiteSettings> SiteSettings => Set<SiteSettings>();
|
||||||
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
|
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
|
||||||
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
|
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
|
||||||
@@ -26,20 +30,37 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
{
|
{
|
||||||
entity.HasIndex(item => item.Year).IsUnique();
|
entity.HasIndex(item => item.Year).IsUnique();
|
||||||
entity.Property(item => item.Name).HasMaxLength(160);
|
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.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 =>
|
modelBuilder.Entity<SiteSettings>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(item => item.HostDisplayName).HasMaxLength(120);
|
entity.Property(item => item.HostDisplayName).HasMaxLength(120);
|
||||||
entity.Property(item => item.HostTagline).HasMaxLength(160);
|
entity.Property(item => item.HostTagline).HasMaxLength(160);
|
||||||
|
entity.Property(item => item.HostArtistName).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.HostImageData).HasColumnType("bytea");
|
||||||
|
entity.Property(item => item.HostImageContentType).HasMaxLength(80);
|
||||||
entity.Property(item => item.NewsletterUrl).HasMaxLength(400);
|
entity.Property(item => item.NewsletterUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
|
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
|
||||||
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
|
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
|
||||||
entity.Property(item => item.ImprintUrl).HasMaxLength(400);
|
entity.Property(item => item.ImprintUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.ContactUrl).HasMaxLength(400);
|
entity.Property(item => item.ContactUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.SponsorsUrl).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.DemoLoginEmail).HasMaxLength(180);
|
||||||
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
|
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
|
||||||
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
|
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
|
||||||
@@ -49,8 +70,21 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
||||||
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
||||||
entity.Property(item => item.TwitchScope).HasMaxLength(300);
|
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.MaintenanceTitle).HasMaxLength(120);
|
||||||
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
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 =>
|
modelBuilder.Entity<TeamMember>(entity =>
|
||||||
@@ -81,24 +115,66 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.GroupName).HasMaxLength(80);
|
entity.Property(item => item.GroupName).HasMaxLength(80);
|
||||||
entity.Property(item => item.Name).HasMaxLength(120);
|
entity.Property(item => item.Name).HasMaxLength(120);
|
||||||
entity.Property(item => item.Description).HasMaxLength(400);
|
entity.Property(item => item.Description).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.ViewerRangeMin);
|
||||||
|
entity.Property(item => item.ViewerRangeMax);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Candidate>(entity =>
|
modelBuilder.Entity<Candidate>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.HasIndex(item => item.StreamerIdentityId);
|
||||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||||
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
||||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
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 =>
|
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.SubmittedByTwitchId).HasMaxLength(120);
|
||||||
entity.Property(item => item.CandidateText).HasMaxLength(120);
|
entity.Property(item => item.CandidateText).HasMaxLength(120);
|
||||||
entity.Property(item => item.StreamUrl).HasMaxLength(300);
|
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.Status).HasMaxLength(20);
|
||||||
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
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 =>
|
modelBuilder.Entity<VoteBallot>(entity =>
|
||||||
@@ -114,6 +190,15 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.CategoryName).HasMaxLength(120);
|
entity.Property(item => item.CategoryName).HasMaxLength(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ArchivedWinner>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(item => new { item.Year, item.Category, item.Subcategory }).IsUnique();
|
||||||
|
entity.Property(item => item.Category).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.Subcategory).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.WinnerName).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.WinnerUrl).HasMaxLength(500);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<UserSession>(entity =>
|
modelBuilder.Entity<UserSession>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasIndex(item => item.SessionToken).IsUnique();
|
entity.HasIndex(item => item.SessionToken).IsUnique();
|
||||||
@@ -163,12 +248,42 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||||
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||||
entity.HasIndex(item => item.CandidateId);
|
entity.HasIndex(item => item.CandidateId);
|
||||||
|
entity.HasOne<Season>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.SeasonId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(item => item.Candidate)
|
entity.HasOne(item => item.Candidate)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(item => item.CandidateId)
|
.HasForeignKey(item => item.CandidateId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+104
-55
@@ -1,17 +1,27 @@
|
|||||||
namespace Backend.Data;
|
namespace Backend.Data;
|
||||||
|
|
||||||
internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder);
|
using Backend.Services;
|
||||||
|
|
||||||
|
internal sealed record AwardCategorySeed(string LegacyGroupName, string Name, string Slug, string Description, int SortOrder);
|
||||||
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
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 WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||||
internal sealed record SiteFaqSeed(string Question, string Answer);
|
internal sealed record SiteFaqSeed(string Question, string Answer);
|
||||||
internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon);
|
internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon);
|
||||||
|
internal sealed record SponsorSeed(string Name, string WebsiteUrl, string LogoUrl, string Description, string Tier, int SortOrder);
|
||||||
|
|
||||||
internal static class SeedCatalog
|
internal static class SeedCatalog
|
||||||
{
|
{
|
||||||
internal static readonly CategorySeed[] CategorySeeds =
|
internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates =
|
||||||
[
|
[
|
||||||
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1),
|
new("Hidden Star", "hidden-star", 1, 1, 20),
|
||||||
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2),
|
new("Rising Star", "rising-star", 2, 21, 60),
|
||||||
|
new("Shining Star", "shining-star", 3, 61, null),
|
||||||
|
];
|
||||||
|
|
||||||
|
internal static readonly AwardCategorySeed[] AwardCategorySeeds =
|
||||||
|
[
|
||||||
|
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die größte Auszeichnung des Jahres.", 1),
|
||||||
|
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie für die Szene.", 2),
|
||||||
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
|
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("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("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
|
||||||
@@ -34,16 +44,16 @@ internal static class SeedCatalog
|
|||||||
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
|
"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(
|
new(
|
||||||
"Wie funktioniert das Voting?",
|
"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."),
|
"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(
|
new(
|
||||||
"Was kostet die Teilnahme?",
|
"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."),
|
"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(
|
new(
|
||||||
"Wann und wo findet die Award-Show statt?",
|
"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!"),
|
"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(
|
new(
|
||||||
"Ich wurde nominiert — was nun?",
|
"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."),
|
"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."),
|
||||||
];
|
];
|
||||||
|
|
||||||
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
|
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
|
||||||
@@ -60,18 +70,18 @@ Anbieter
|
|||||||
VTuber Star Awards, vertreten durch Jayuhime.
|
VTuber Star Awards, vertreten durch Jayuhime.
|
||||||
|
|
||||||
Kontakt
|
Kontakt
|
||||||
Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
Nutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||||
|
|
||||||
Hinweis
|
Hinweis
|
||||||
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.
|
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
internal const string DefaultContactContent = """
|
internal const string DefaultContactContent = """
|
||||||
Kontakt zum Award-Team
|
Kontakt zum Award-Team
|
||||||
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.
|
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.
|
||||||
|
|
||||||
Datenschutzfragen
|
Datenschutzfragen
|
||||||
Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
Für Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||||
|
|
||||||
Community & Kooperationen
|
Community & Kooperationen
|
||||||
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||||
@@ -79,57 +89,96 @@ Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
|||||||
|
|
||||||
internal const string DefaultSponsorsContent = """
|
internal const string DefaultSponsorsContent = """
|
||||||
Sponsoren & Partner
|
Sponsoren & Partner
|
||||||
Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
Hier können 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.
|
Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
||||||
[
|
[
|
||||||
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||||
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"),
|
new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"),
|
new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||||
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||||
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||||
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||||
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||||
new("best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"),
|
new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||||
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||||
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||||
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
new("community-liebling", "Lumi", "@lumi_vt", "Cake"),
|
new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||||
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"),
|
new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||||
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||||
];
|
];
|
||||||
|
|
||||||
internal static readonly WinnerSeed[] WinnerSeeds =
|
internal static readonly WinnerSeed[] WinnerSeeds =
|
||||||
[
|
[
|
||||||
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||||
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
|
new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"),
|
new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||||
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
|
new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||||
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"),
|
new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
|
new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||||
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
|
new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"),
|
new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"),
|
||||||
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"),
|
new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"),
|
new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||||
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
|
new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||||
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
|
new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||||
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"),
|
new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"),
|
||||||
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||||
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"),
|
new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"),
|
||||||
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||||
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"),
|
new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"),
|
||||||
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"),
|
new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||||
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"),
|
new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||||
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"),
|
new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||||
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"),
|
new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"),
|
||||||
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"),
|
new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"),
|
||||||
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"),
|
new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
|
||||||
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"),
|
new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"),
|
||||||
|
];
|
||||||
|
|
||||||
|
internal static readonly SponsorSeed[] DemoSponsorSeeds =
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
"HoshiForge Studio",
|
||||||
|
"https://hoshiforge.example",
|
||||||
|
"/demo/sponsors/hoshiforge-studio.svg",
|
||||||
|
"Branding-, Overlay- und Debuet-Visuals fuer VTuber-Projekte und Community-Events.",
|
||||||
|
"Presenting Sponsor",
|
||||||
|
10),
|
||||||
|
new(
|
||||||
|
"NekoPixel Energy",
|
||||||
|
"https://nekopixel.example",
|
||||||
|
"/demo/sponsors/nekopixel-energy.svg",
|
||||||
|
"Community-fokussierter Drink-Partner fuer lange Showabende, Watchpartys und Creator-Collabs.",
|
||||||
|
"Gold Partner",
|
||||||
|
20),
|
||||||
|
new(
|
||||||
|
"PrismLoop Audio",
|
||||||
|
"https://prismloop.example",
|
||||||
|
"/demo/sponsors/prismloop-audio.svg",
|
||||||
|
"Audio-Tools, Intro-Packs und Stream-Sounddesign fuer Live-Shows und Highlight-Clips.",
|
||||||
|
"Gold Partner",
|
||||||
|
30),
|
||||||
|
new(
|
||||||
|
"CloudBeacon Hosting",
|
||||||
|
"https://cloudbeacon.example",
|
||||||
|
"/demo/sponsors/cloudbeacon-hosting.svg",
|
||||||
|
"Skalierbares Hosting fuer Voting, Landingpages und Event-Traffic rund um Showtage.",
|
||||||
|
"Tech Partner",
|
||||||
|
40),
|
||||||
|
new(
|
||||||
|
"ChibiCanvas Market",
|
||||||
|
"https://chibicanvas.example",
|
||||||
|
"/demo/sponsors/chibicanvas-market.svg",
|
||||||
|
"Merch-, Sticker- und Artist-Marketplace mit Fokus auf VTuber, Emotes und Fanartikel.",
|
||||||
|
"Community Partner",
|
||||||
|
50),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class ArchivedWinner
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int Year { get; set; }
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public string Subcategory { get; set; } = string.Empty;
|
||||||
|
public string WinnerName { get; set; } = string.Empty;
|
||||||
|
public string WinnerUrl { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -7,7 +7,16 @@ public sealed class Candidate
|
|||||||
public Season Season { get; set; } = null!;
|
public Season Season { get; set; } = null!;
|
||||||
public int CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public Category Category { get; set; } = null!;
|
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 DisplayName { get; set; } = string.Empty;
|
||||||
public string ChannelSlug { get; set; } = string.Empty;
|
public string ChannelSlug { get; set; } = string.Empty;
|
||||||
public string Platform { get; set; } = "Twitch";
|
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 string Description { get; set; } = string.Empty;
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int MaxNomineesPerUser { get; set; }
|
public int MaxNomineesPerUser { get; set; }
|
||||||
|
public int? ViewerRangeMin { get; set; }
|
||||||
|
public int? ViewerRangeMax { get; set; }
|
||||||
public ICollection<Candidate> Candidates { get; set; } = [];
|
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,32 @@ public sealed class Nomination
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int SeasonId { get; set; }
|
public int SeasonId { get; set; }
|
||||||
public Season Season { get; set; } = null!;
|
public Season Season { get; set; } = null!;
|
||||||
public int CategoryId { get; set; }
|
public int? CategoryId { get; set; }
|
||||||
public Category Category { get; set; } = null!;
|
public Category? Category { get; set; }
|
||||||
|
public string CategoryGroupName { get; set; } = string.Empty;
|
||||||
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
||||||
public int? CandidateId { get; set; }
|
public int? CandidateId { get; set; }
|
||||||
public Candidate? Candidate { 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? CandidateText { get; set; }
|
||||||
public string? StreamUrl { 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 Status { get; set; } = "pending";
|
||||||
public string? ReviewNote { get; set; }
|
public string? ReviewNote { get; set; }
|
||||||
public string? ReviewedByTwitchId { get; set; }
|
public string? ReviewedByTwitchId { get; set; }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public sealed class Season
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
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 IsCurrent { get; set; }
|
||||||
public bool IsCommunityOnly { get; set; }
|
public bool IsCommunityOnly { get; set; }
|
||||||
public string CurrentPhase { get; set; } = string.Empty;
|
public string CurrentPhase { get; set; } = string.Empty;
|
||||||
@@ -17,6 +17,10 @@ public sealed class Season
|
|||||||
public DateOnly ReviewEndsAt { get; set; }
|
public DateOnly ReviewEndsAt { get; set; }
|
||||||
public DateOnly ShowDate { get; set; }
|
public DateOnly ShowDate { get; set; }
|
||||||
public TimeOnly ShowStartsAt { get; set; } = new(20, 0);
|
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<Category> Categories { get; set; } = [];
|
||||||
public ICollection<AwardResult> Results { 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; }
|
||||||
|
}
|
||||||
@@ -5,7 +5,13 @@ public sealed class SiteSettings
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string HostDisplayName { get; set; } = string.Empty;
|
public string HostDisplayName { get; set; } = string.Empty;
|
||||||
public string HostTagline { get; set; } = string.Empty;
|
public string HostTagline { get; set; } = string.Empty;
|
||||||
|
public string HostArtistName { get; set; } = string.Empty;
|
||||||
|
public byte[]? HostImageData { get; set; }
|
||||||
|
public string? HostImageContentType { get; set; }
|
||||||
|
public DateTimeOffset? HostImageUpdatedAt { get; set; }
|
||||||
public string NewsletterUrl { 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 PrivacyEmail { get; set; } = string.Empty;
|
||||||
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
||||||
public string? PrivacyPolicyUpdatedBy { get; set; }
|
public string? PrivacyPolicyUpdatedBy { get; set; }
|
||||||
@@ -16,9 +22,42 @@ public sealed class SiteSettings
|
|||||||
public string ContactContent { get; set; } = string.Empty;
|
public string ContactContent { get; set; } = string.Empty;
|
||||||
public string SponsorsUrl { get; set; } = string.Empty;
|
public string SponsorsUrl { get; set; } = string.Empty;
|
||||||
public string SponsorsContent { 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 SocialLinksJson { get; set; } = "[]";
|
||||||
public string FaqJson { get; set; } = "[]";
|
public string FaqJson { get; set; } = "[]";
|
||||||
public string RiskRulesJson { 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 DemoLoginManagedByDatabase { get; set; }
|
||||||
public bool DemoLoginEnabled { get; set; }
|
public bool DemoLoginEnabled { get; set; }
|
||||||
public string DemoLoginEmail { get; set; } = string.Empty;
|
public string DemoLoginEmail { get; set; } = string.Empty;
|
||||||
@@ -31,6 +70,7 @@ public sealed class SiteSettings
|
|||||||
public string TwitchClientSecret { get; set; } = string.Empty;
|
public string TwitchClientSecret { get; set; } = string.Empty;
|
||||||
public string TwitchRedirectUri { get; set; } = string.Empty;
|
public string TwitchRedirectUri { get; set; } = string.Empty;
|
||||||
public string TwitchScope { get; set; } = string.Empty;
|
public string TwitchScope { get; set; } = string.Empty;
|
||||||
|
public int SessionIdleTimeoutHours { get; set; } = 3;
|
||||||
public bool MaintenanceModeEnabled { get; set; }
|
public bool MaintenanceModeEnabled { get; set; }
|
||||||
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
||||||
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
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; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminArchiveEndpoints
|
||||||
|
{
|
||||||
|
public static RouteGroupBuilder MapAdminArchiveEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/archived-winners", GetArchivedWinners)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("GetAdminArchivedWinners");
|
||||||
|
|
||||||
|
group.MapPost("/archived-winners", CreateArchivedWinner)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("CreateAdminArchivedWinner");
|
||||||
|
|
||||||
|
group.MapPut("/archived-winners/{archivedWinnerId:int}", UpdateArchivedWinner)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("UpdateAdminArchivedWinner");
|
||||||
|
|
||||||
|
group.MapDelete("/archived-winners/{archivedWinnerId:int}", DeleteArchivedWinner)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("DeleteAdminArchivedWinner");
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetArchivedWinners(AwardsDbContext db, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await db.ArchivedWinners
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(item => item.Year)
|
||||||
|
.ThenBy(item => item.Category)
|
||||||
|
.ThenBy(item => item.Subcategory)
|
||||||
|
.ThenBy(item => item.WinnerName)
|
||||||
|
.Select(item => ToDto(item))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Results.Ok(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateArchivedWinner(
|
||||||
|
HttpContext context,
|
||||||
|
UpsertArchivedWinnerRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var validation = ValidateRequest(request);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizeRequest(request);
|
||||||
|
var duplicateExists = await db.ArchivedWinners.AnyAsync(item =>
|
||||||
|
item.Year == normalized.Year
|
||||||
|
&& item.Category == normalized.Category
|
||||||
|
&& item.Subcategory == normalized.Subcategory,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (duplicateExists)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var archivedWinner = new ArchivedWinner
|
||||||
|
{
|
||||||
|
Year = normalized.Year,
|
||||||
|
Category = normalized.Category,
|
||||||
|
Subcategory = normalized.Subcategory,
|
||||||
|
WinnerName = normalized.WinnerName,
|
||||||
|
WinnerUrl = normalized.WinnerUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ArchivedWinners.Add(archivedWinner);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"archived-winner.create",
|
||||||
|
"archivedWinner",
|
||||||
|
$"{normalized.Year}:{normalized.Category}:{normalized.Subcategory}",
|
||||||
|
$"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} angelegt.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
normalized.Year,
|
||||||
|
normalized.Category,
|
||||||
|
normalized.Subcategory,
|
||||||
|
normalized.WinnerName,
|
||||||
|
normalized.WinnerUrl,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateArchivedWinner(
|
||||||
|
HttpContext context,
|
||||||
|
int archivedWinnerId,
|
||||||
|
UpsertArchivedWinnerRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var validation = ValidateRequest(request);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted);
|
||||||
|
if (archivedWinner is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizeRequest(request);
|
||||||
|
var duplicateExists = await db.ArchivedWinners.AnyAsync(item =>
|
||||||
|
item.Id != archivedWinnerId
|
||||||
|
&& item.Year == normalized.Year
|
||||||
|
&& item.Category == normalized.Category
|
||||||
|
&& item.Subcategory == normalized.Subcategory,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (duplicateExists)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." });
|
||||||
|
}
|
||||||
|
|
||||||
|
archivedWinner.Year = normalized.Year;
|
||||||
|
archivedWinner.Category = normalized.Category;
|
||||||
|
archivedWinner.Subcategory = normalized.Subcategory;
|
||||||
|
archivedWinner.WinnerName = normalized.WinnerName;
|
||||||
|
archivedWinner.WinnerUrl = normalized.WinnerUrl;
|
||||||
|
archivedWinner.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"archived-winner.update",
|
||||||
|
"archivedWinner",
|
||||||
|
archivedWinner.Id.ToString(),
|
||||||
|
$"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} aktualisiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
archivedWinner.Id,
|
||||||
|
normalized.Year,
|
||||||
|
normalized.Category,
|
||||||
|
normalized.Subcategory,
|
||||||
|
normalized.WinnerName,
|
||||||
|
normalized.WinnerUrl,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteArchivedWinner(
|
||||||
|
HttpContext context,
|
||||||
|
int archivedWinnerId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted);
|
||||||
|
if (archivedWinner is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ArchivedWinners.Remove(archivedWinner);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"archived-winner.delete",
|
||||||
|
"archivedWinner",
|
||||||
|
archivedWinner.Id.ToString(),
|
||||||
|
$"Archivgewinner {archivedWinner.Year} · {archivedWinner.Category} · {archivedWinner.Subcategory} gelöscht.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
archivedWinner.Id,
|
||||||
|
archivedWinner.Year,
|
||||||
|
archivedWinner.Category,
|
||||||
|
archivedWinner.Subcategory,
|
||||||
|
archivedWinner.WinnerName,
|
||||||
|
archivedWinner.WinnerUrl,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, archivedWinnerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminArchivedWinnerItemDto ToDto(ArchivedWinner item) =>
|
||||||
|
new(
|
||||||
|
item.Id,
|
||||||
|
item.Year,
|
||||||
|
item.Category,
|
||||||
|
item.Subcategory,
|
||||||
|
item.WinnerName,
|
||||||
|
item.WinnerUrl,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.UpdatedAt);
|
||||||
|
|
||||||
|
private static IResult? ValidateRequest(UpsertArchivedWinnerRequest request)
|
||||||
|
{
|
||||||
|
if (request.Year < 2000 || request.Year > 3000)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte ein gültiges Archivjahr angeben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Category))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Die Kategorie darf nicht leer sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Subcategory))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Die Unterkategorie darf nicht leer sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.WinnerName))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Der Gewinnername darf nicht leer sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var winnerUrl = request.WinnerUrl?.Trim() ?? string.Empty;
|
||||||
|
if (!Uri.TryCreate(winnerUrl, UriKind.Absolute, out var uri)
|
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte einen gültigen http- oder https-Link angeben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int Year, string Category, string Subcategory, string WinnerName, string WinnerUrl) NormalizeRequest(UpsertArchivedWinnerRequest request) =>
|
||||||
|
(
|
||||||
|
request.Year,
|
||||||
|
SeasonMappings.NormalizePlainTextContent(request.Category),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(request.Subcategory),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(request.WinnerName),
|
||||||
|
request.WinnerUrl.Trim());
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
|
using Backend.Common;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
using Backend.Security;
|
using Backend.Security;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -20,37 +21,39 @@ public static class AdminDashboardEndpoints
|
|||||||
return group;
|
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 canViewAuditIp = CanViewAuditIp(context);
|
||||||
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
|
var selectedSeason = seasonId.HasValue
|
||||||
if (currentSeason is null)
|
? 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();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
|
var selectedSeasonId = selectedSeason.Id;
|
||||||
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id);
|
var phaseKey = SeasonMappings.NormalizePhaseKey(selectedSeason.CurrentPhase);
|
||||||
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id);
|
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId);
|
||||||
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.Status == "pending");
|
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == selectedSeasonId);
|
||||||
var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
|
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
|
var topCategories = phaseKey == "nomination"
|
||||||
.AsNoTracking()
|
? await BuildTopNominationCategoriesAsync(db, selectedSeasonId)
|
||||||
.Where(item => item.Ballot.SeasonId == currentSeason.Id)
|
: await BuildTopVotingCategoriesAsync(db, selectedSeasonId);
|
||||||
.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 riskFlags = await db.RiskFlags
|
var riskFlags = await db.RiskFlags
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.Status == "open")
|
.Where(item =>
|
||||||
|
item.Status == "open" &&
|
||||||
|
(item.SeasonId == selectedSeasonId || item.SeasonId == null))
|
||||||
.OrderByDescending(item => item.CreatedAt)
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
.Take(8)
|
.Take(8)
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
@@ -74,18 +77,27 @@ public static class AdminDashboardEndpoints
|
|||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var activityItems = auditEntries
|
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."))
|
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return Results.Ok(new AdminDashboardResponse(
|
return Results.Ok(new AdminDashboardResponse(
|
||||||
|
selectedSeason.Id,
|
||||||
|
selectedSeason.Year,
|
||||||
|
selectedSeason.Name,
|
||||||
|
selectedSeason.IsCurrent,
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
|
new AdminMetricDto("Nominierungen", nominationCount, $"Gespeicherte Einreichungen im Award-Jahr {selectedSeason.Year}"),
|
||||||
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"),
|
new AdminMetricDto("Stimmen", voteCount, $"Abgegebene Stimmen im Award-Jahr {selectedSeason.Year}"),
|
||||||
new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"),
|
new AdminMetricDto("Kategorien", categoryCount, $"Aktive Kategorien im Award-Jahr {selectedSeason.Year}"),
|
||||||
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf"),
|
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf in diesem Jahr"),
|
||||||
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
|
new AdminMetricDto(
|
||||||
|
"Risikohinweise",
|
||||||
|
riskFlagCount,
|
||||||
|
globalRiskFlagCount > 0
|
||||||
|
? $"Offene Hinweise fuer {selectedSeason.Year}, inklusive {globalRiskFlagCount} globaler Hinweise"
|
||||||
|
: $"Offene Hinweise fuer {selectedSeason.Year}"),
|
||||||
},
|
},
|
||||||
activityItems,
|
activityItems,
|
||||||
topCategories,
|
topCategories,
|
||||||
@@ -93,6 +105,45 @@ public static class AdminDashboardEndpoints
|
|||||||
auditEntries));
|
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(
|
private static async Task<IResult> GetAuditEntries(
|
||||||
int? limit,
|
int? limit,
|
||||||
string? query,
|
string? query,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
group.MapAdminDashboardEndpoints();
|
group.MapAdminDashboardEndpoints();
|
||||||
group.MapAdminSeasonManagementEndpoints();
|
group.MapAdminSeasonManagementEndpoints();
|
||||||
|
group.MapAdminArchiveEndpoints();
|
||||||
group.MapAdminModerationEndpoints();
|
group.MapAdminModerationEndpoints();
|
||||||
|
group.MapAdminExtrasEndpoints();
|
||||||
group.MapAdminTeamEndpoints();
|
group.MapAdminTeamEndpoints();
|
||||||
|
|
||||||
return app;
|
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))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
.WithName("RejectAdminNomination")
|
.WithName("RejectAdminNomination")
|
||||||
.WithOpenApi();
|
.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)
|
group.MapGet("/risk-flags", GetRiskFlags)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||||
.WithName("GetAdminRiskFlags")
|
.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 session = AdminEndpointConventions.CurrentSession(context);
|
||||||
var nomination = await db.Nominations
|
var nomination = await db.Nominations
|
||||||
.Include(item => item.Category)
|
.Include(item => item.Category)
|
||||||
|
.Include(item => item.SuggestedCategory)
|
||||||
|
.Include(item => item.StreamerIdentity)
|
||||||
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||||
|
|
||||||
if (nomination is null)
|
if (nomination is null)
|
||||||
@@ -26,36 +28,71 @@ public static partial class AdminModerationEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
|
||||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
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 normalizedDisplayName = rawDisplayName.ToLower();
|
||||||
var normalizedChannelSlug = channelSlug.ToLower();
|
var normalizedChannelSlug = channelSlug.ToLower();
|
||||||
var normalizedPlatform = platform.ToLower();
|
var normalizedPlatform = platform.ToLower();
|
||||||
|
|
||||||
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||||
item.SeasonId == nomination.SeasonId
|
item.SeasonId == nomination.SeasonId
|
||||||
&& item.CategoryId == nomination.CategoryId
|
&& item.CategoryId == targetCategory.Id
|
||||||
&& (
|
&& (
|
||||||
|
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||||
|
||
|
||||||
item.DisplayName.ToLower() == normalizedDisplayName
|
item.DisplayName.ToLower() == normalizedDisplayName
|
||||||
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||||
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||||
&& item.Platform.ToLower() == normalizedPlatform)
|
&& 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;
|
var candidate = existingCandidate;
|
||||||
if (candidate is null)
|
if (candidate is null)
|
||||||
{
|
{
|
||||||
candidate = new Candidate
|
candidate = new Candidate
|
||||||
{
|
{
|
||||||
SeasonId = nomination.SeasonId,
|
SeasonId = nomination.SeasonId,
|
||||||
CategoryId = nomination.CategoryId,
|
CategoryId = targetCategory.Id,
|
||||||
|
StreamerIdentityId = nomination.StreamerIdentityId,
|
||||||
DisplayName = rawDisplayName,
|
DisplayName = rawDisplayName,
|
||||||
ChannelSlug = channelSlug,
|
ChannelSlug = channelSlug,
|
||||||
Platform = platform,
|
Platform = platform,
|
||||||
@@ -66,6 +103,7 @@ public static partial class AdminModerationEndpoints
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
|
||||||
candidate.DisplayName = rawDisplayName;
|
candidate.DisplayName = rawDisplayName;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||||
@@ -79,23 +117,54 @@ public static partial class AdminModerationEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nomination.CandidateId = candidate.Id;
|
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||||
nomination.Status = "approved";
|
var uniqueViewerCount = relatedNominations
|
||||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
|
||||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
.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(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
"nomination.approve",
|
"nomination.approve",
|
||||||
"nomination",
|
"nomination",
|
||||||
nomination.Id.ToString(),
|
nomination.Id.ToString(),
|
||||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
|
||||||
new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote },
|
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));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
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(
|
private static async Task<IResult> RejectNomination(
|
||||||
@@ -112,11 +181,16 @@ public static partial class AdminModerationEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
nomination.CandidateId = null;
|
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||||
nomination.Status = "rejected";
|
foreach (var relatedNomination in relatedNominations)
|
||||||
nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
{
|
||||||
nomination.ReviewedAt = DateTimeOffset.UtcNow;
|
relatedNomination.CandidateId = null;
|
||||||
nomination.ReviewedByTwitchId = session.TwitchUserId;
|
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(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -124,10 +198,257 @@ public static partial class AdminModerationEndpoints
|
|||||||
"nomination",
|
"nomination",
|
||||||
nomination.Id.ToString(),
|
nomination.Id.ToString(),
|
||||||
$"Nominierung {nomination.Id} wurde verworfen.",
|
$"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));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,10 +151,15 @@ public static partial class AdminModerationEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> BulkResolveRiskFlags(
|
private static async Task<IResult> BulkResolveRiskFlags(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
BulkResolveRiskFlagsRequest request,
|
BulkResolveRiskFlagsRequest? request,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IAdminAuditService adminAuditService)
|
IAdminAuditService adminAuditService)
|
||||||
{
|
{
|
||||||
|
if (request is null || request.RiskFlagIds is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht mindestens einen Risikohinweis." });
|
||||||
|
}
|
||||||
|
|
||||||
var session = AdminEndpointConventions.CurrentSession(context);
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
var normalizedIds = request.RiskFlagIds
|
var normalizedIds = request.RiskFlagIds
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -188,7 +193,7 @@ public static partial class AdminModerationEndpoints
|
|||||||
|
|
||||||
if (riskFlags.Any(item => item.Severity != "low"))
|
if (riskFlags.Any(item => item.Severity != "low"))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer Low-Severity-Hinweise erlaubt." });
|
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Low-Severity-Hinweise erlaubt." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (riskFlags.Any(item => item.Status != "open"))
|
if (riskFlags.Any(item => item.Status != "open"))
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ namespace Backend.Endpoints;
|
|||||||
|
|
||||||
public static partial class AdminSeasonManagementEndpoints
|
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(
|
private static async Task<IResult> CreateCandidate(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
int seasonId,
|
int seasonId,
|
||||||
@@ -31,6 +34,20 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
var normalizedDisplayName = request.DisplayName.Trim();
|
var normalizedDisplayName = request.DisplayName.Trim();
|
||||||
var normalizedChannelSlug = request.ChannelSlug.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 =>
|
if (await db.Candidates.AnyAsync(item =>
|
||||||
item.SeasonId == seasonId
|
item.SeasonId == seasonId
|
||||||
&& item.CategoryId == request.CategoryId
|
&& 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." });
|
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
|
var candidate = new Candidate
|
||||||
{
|
{
|
||||||
SeasonId = seasonId,
|
SeasonId = seasonId,
|
||||||
@@ -47,6 +79,12 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
DisplayName = normalizedDisplayName,
|
DisplayName = normalizedDisplayName,
|
||||||
ChannelSlug = normalizedChannelSlug,
|
ChannelSlug = normalizedChannelSlug,
|
||||||
Platform = request.Platform.Trim(),
|
Platform = request.Platform.Trim(),
|
||||||
|
AcceptanceStatus = normalizedAcceptanceStatus,
|
||||||
|
AcceptanceNote = normalizedAcceptanceNote,
|
||||||
|
ClipCompilationUrl = normalizedClipUrl,
|
||||||
|
ClipCompilationTitle = normalizedClipTitle,
|
||||||
|
ClipCompilationPlatform = normalizedClipPlatform,
|
||||||
|
ClipEmbedStatus = normalizedClipEmbedStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Candidates.Add(candidate);
|
db.Candidates.Add(candidate);
|
||||||
@@ -56,7 +94,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
"candidate",
|
"candidate",
|
||||||
request.DisplayName.Trim(),
|
request.DisplayName.Trim(),
|
||||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
$"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));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
@@ -92,6 +130,20 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
var normalizedDisplayName = request.DisplayName.Trim();
|
var normalizedDisplayName = request.DisplayName.Trim();
|
||||||
var normalizedChannelSlug = request.ChannelSlug.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 =>
|
if (await db.Candidates.AnyAsync(item =>
|
||||||
item.SeasonId == candidate.SeasonId
|
item.SeasonId == candidate.SeasonId
|
||||||
&& item.CategoryId == request.CategoryId
|
&& 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." });
|
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.CategoryId = request.CategoryId;
|
||||||
candidate.DisplayName = normalizedDisplayName;
|
candidate.DisplayName = normalizedDisplayName;
|
||||||
candidate.ChannelSlug = normalizedChannelSlug;
|
candidate.ChannelSlug = normalizedChannelSlug;
|
||||||
candidate.Platform = request.Platform.Trim();
|
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(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -113,13 +186,41 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
"candidate",
|
"candidate",
|
||||||
candidate.Id.ToString(),
|
candidate.Id.ToString(),
|
||||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
$"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));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetCandidateDeletePreview(
|
||||||
|
int candidateId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidate = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == candidateId, cancellationToken);
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nominationCount = await db.Nominations.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
||||||
|
var clipCount = await db.ClipSubmissions.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
||||||
|
var voteCount = await db.VoteEntries.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
||||||
|
var resultCount = await db.Results.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
candidateId,
|
||||||
|
nominationCount,
|
||||||
|
clipCount,
|
||||||
|
voteCount,
|
||||||
|
resultCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DeleteCandidate(
|
private static async Task<IResult> DeleteCandidate(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
int candidateId,
|
int candidateId,
|
||||||
@@ -133,6 +234,38 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var linkedNominations = await db.Nominations
|
||||||
|
.Where(item => item.CandidateId == candidateId)
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
if (linkedNominations.Count > 0)
|
||||||
|
{
|
||||||
|
db.Nominations.RemoveRange(linkedNominations);
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkedClips = await db.ClipSubmissions
|
||||||
|
.Where(item => item.CandidateId == candidateId)
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
if (linkedClips.Count > 0)
|
||||||
|
{
|
||||||
|
db.ClipSubmissions.RemoveRange(linkedClips);
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkedVoteEntries = await db.VoteEntries
|
||||||
|
.Where(item => item.CandidateId == candidateId)
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
if (linkedVoteEntries.Count > 0)
|
||||||
|
{
|
||||||
|
db.VoteEntries.RemoveRange(linkedVoteEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkedResults = await db.Results
|
||||||
|
.Where(item => item.CandidateId == candidateId)
|
||||||
|
.ToListAsync(context.RequestAborted);
|
||||||
|
if (linkedResults.Count > 0)
|
||||||
|
{
|
||||||
|
db.Results.RemoveRange(linkedResults);
|
||||||
|
}
|
||||||
|
|
||||||
db.Candidates.Remove(candidate);
|
db.Candidates.Remove(candidate);
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -140,10 +273,58 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
"candidate",
|
"candidate",
|
||||||
candidate.Id.ToString(),
|
candidate.Id.ToString(),
|
||||||
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
|
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
|
||||||
new { candidate.CategoryId, candidate.Platform },
|
new
|
||||||
|
{
|
||||||
|
candidate.CategoryId,
|
||||||
|
candidate.Platform,
|
||||||
|
deletedNominations = linkedNominations.Count,
|
||||||
|
deletedClips = linkedClips.Count,
|
||||||
|
deletedVoteEntries = linkedVoteEntries.Count,
|
||||||
|
deletedResults = linkedResults.Count,
|
||||||
|
},
|
||||||
RequestMetadataReader.Read(context));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
try
|
||||||
return Results.Ok(new { deleted = true, candidateId });
|
{
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, candidateId });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new
|
||||||
|
{
|
||||||
|
message = "Kandidat konnte nicht gelöscht werden, weil noch verknüpfte Daten blockieren.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
Description = request.Description.Trim(),
|
||||||
SortOrder = request.SortOrder,
|
SortOrder = request.SortOrder,
|
||||||
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||||
|
ViewerRangeMin = request.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = request.ViewerRangeMax,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Categories.Add(category);
|
db.Categories.Add(category);
|
||||||
@@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
category.Description = request.Description.Trim();
|
category.Description = request.Description.Trim();
|
||||||
category.SortOrder = request.SortOrder;
|
category.SortOrder = request.SortOrder;
|
||||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||||
|
category.ViewerRangeMin = request.ViewerRangeMin;
|
||||||
|
category.ViewerRangeMax = request.ViewerRangeMax;
|
||||||
|
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
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." });
|
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
|
var season = new Season
|
||||||
{
|
{
|
||||||
Year = request.Year,
|
Year = request.Year,
|
||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
ShowStreamUrl = showStreamUrl,
|
IsDemo = false,
|
||||||
CurrentPhase = request.CurrentPhase.Trim(),
|
CurrentPhase = request.CurrentPhase.Trim(),
|
||||||
IsCurrent = request.IsCurrent,
|
IsCurrent = request.IsCurrent,
|
||||||
IsCommunityOnly = request.IsCommunityOnly,
|
IsCommunityOnly = request.IsCommunityOnly,
|
||||||
@@ -45,6 +48,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
ReviewEndsAt = request.ReviewEndsAt,
|
ReviewEndsAt = request.ReviewEndsAt,
|
||||||
ShowDate = request.ShowDate,
|
ShowDate = request.ShowDate,
|
||||||
ShowStartsAt = request.ShowStartsAt,
|
ShowStartsAt = request.ShowStartsAt,
|
||||||
|
SubcategoryTemplatesJson = "[]",
|
||||||
|
WorkflowRulesJson = initialWorkflowRulesJson,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Seasons.Add(season);
|
db.Seasons.Add(season);
|
||||||
@@ -72,6 +77,13 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.ToArrayAsync(context.RequestAborted);
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
|
||||||
copiedCategoryCount = sourceCategories.Length;
|
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)
|
foreach (var category in sourceCategories)
|
||||||
{
|
{
|
||||||
db.Categories.Add(new Category
|
db.Categories.Add(new Category
|
||||||
@@ -83,6 +95,8 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
Description = category.Description,
|
Description = category.Description,
|
||||||
SortOrder = category.SortOrder,
|
SortOrder = category.SortOrder,
|
||||||
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||||
|
ViewerRangeMin = category.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = category.ViewerRangeMax,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +124,6 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
{
|
{
|
||||||
request.IsCurrent,
|
request.IsCurrent,
|
||||||
request.IsCommunityOnly,
|
request.IsCommunityOnly,
|
||||||
showStreamUrl,
|
|
||||||
request.CurrentPhase,
|
request.CurrentPhase,
|
||||||
request.ShowDate,
|
request.ShowDate,
|
||||||
request.ShowStartsAt,
|
request.ShowStartsAt,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
|
using Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
@@ -17,19 +18,32 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var candidates = await db.Candidates
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||||
|
|
||||||
|
var candidateRows = await db.Candidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
.OrderBy(item => item.DisplayName)
|
.OrderBy(item => item.DisplayName)
|
||||||
.Select(item => new AdminCandidateItemDto(
|
.Select(item => new AdminCandidateRow(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
item.DisplayName,
|
item.DisplayName,
|
||||||
item.ChannelSlug,
|
item.ChannelSlug,
|
||||||
item.Platform))
|
item.Platform,
|
||||||
|
item.NominationTally,
|
||||||
|
item.AcceptanceStatus,
|
||||||
|
item.AcceptanceNote,
|
||||||
|
item.ClipCompilationUrl,
|
||||||
|
item.ClipCompilationTitle,
|
||||||
|
item.ClipCompilationPlatform,
|
||||||
|
item.ClipEmbedStatus))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var candidateCounts = candidates
|
var candidateCounts = candidateRows
|
||||||
.GroupBy(item => item.CategoryId)
|
.GroupBy(item => item.CategoryId)
|
||||||
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
|
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
|
||||||
|
|
||||||
@@ -47,9 +61,21 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
category.Description,
|
category.Description,
|
||||||
category.SortOrder,
|
category.SortOrder,
|
||||||
category.MaxNomineesPerUser,
|
category.MaxNomineesPerUser,
|
||||||
|
category.ViewerRangeMin,
|
||||||
|
category.ViewerRangeMax,
|
||||||
})
|
})
|
||||||
.ToArrayAsync();
|
.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 categories = categoryRows
|
var categories = categoryRows
|
||||||
.Select(category => new AdminCategoryItemDto(
|
.Select(category => new AdminCategoryItemDto(
|
||||||
category.Id,
|
category.Id,
|
||||||
@@ -59,20 +85,42 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
category.Description,
|
category.Description,
|
||||||
category.SortOrder,
|
category.SortOrder,
|
||||||
category.MaxNomineesPerUser,
|
category.MaxNomineesPerUser,
|
||||||
|
category.ViewerRangeMin,
|
||||||
|
category.ViewerRangeMax,
|
||||||
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var pendingNominations = await db.Nominations
|
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||||
|
|
||||||
|
var pendingNominationRows = await db.Nominations
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||||
.OrderByDescending(item => item.CreatedAt)
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
.Select(item => new AdminNominationReviewItemDto(
|
.Select(item => new AdminNominationRow(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.Category.Name,
|
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||||
|
item.CategoryId != null ? item.Category!.Name : null,
|
||||||
item.SubmittedByTwitchId,
|
item.SubmittedByTwitchId,
|
||||||
item.CandidateText ?? string.Empty,
|
item.CandidateText ?? string.Empty,
|
||||||
item.StreamUrl,
|
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.Status,
|
||||||
item.CreatedAt,
|
item.CreatedAt,
|
||||||
item.CandidateId,
|
item.CandidateId,
|
||||||
@@ -82,17 +130,41 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.ReviewedAt))
|
item.ReviewedAt))
|
||||||
.ToArrayAsync();
|
.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()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||||
.Select(item => new AdminNominationReviewItemDto(
|
.Select(item => new AdminNominationRow(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.Category.Name,
|
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||||
|
item.CategoryId != null ? item.Category!.Name : null,
|
||||||
item.SubmittedByTwitchId,
|
item.SubmittedByTwitchId,
|
||||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||||
item.StreamUrl,
|
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.Status,
|
||||||
item.CreatedAt,
|
item.CreatedAt,
|
||||||
item.CandidateId,
|
item.CandidateId,
|
||||||
@@ -102,6 +174,10 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.ReviewedAt))
|
item.ReviewedAt))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var reviewedNominations = reviewedNominationRows
|
||||||
|
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var resultItems = await db.Results
|
var resultItems = await db.Results
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -112,11 +188,34 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.Category.Name,
|
item.Category.Name,
|
||||||
item.CandidateId,
|
item.CandidateId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
item.Candidate.DisplayName,
|
item.Candidate.DisplayName,
|
||||||
item.Candidate.ChannelSlug,
|
item.Candidate.ChannelSlug,
|
||||||
item.Candidate.Platform))
|
item.Candidate.Platform))
|
||||||
.ToArrayAsync();
|
.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 candidates = BuildCandidateItems(
|
||||||
|
candidateRows,
|
||||||
|
pendingNominationRows,
|
||||||
|
reviewedNominationRows,
|
||||||
|
votingEntryRows);
|
||||||
|
var votingWorkspace = BuildVotingWorkspace(
|
||||||
|
categories,
|
||||||
|
candidates,
|
||||||
|
pendingNominations,
|
||||||
|
resultItems,
|
||||||
|
votingEntryRows,
|
||||||
|
workflowRules);
|
||||||
|
|
||||||
var clipSubmissions = await db.ClipSubmissions
|
var clipSubmissions = await db.ClipSubmissions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -141,7 +240,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
season.Id,
|
season.Id,
|
||||||
season.Year,
|
season.Year,
|
||||||
season.Name,
|
season.Name,
|
||||||
NormalizeSeasonStreamUrl(season.ShowStreamUrl),
|
season.IsDemo,
|
||||||
season.CurrentPhase,
|
season.CurrentPhase,
|
||||||
season.IsCurrent,
|
season.IsCurrent,
|
||||||
season.IsCommunityOnly,
|
season.IsCommunityOnly,
|
||||||
@@ -153,11 +252,554 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
season.ReviewEndsAt,
|
season.ReviewEndsAt,
|
||||||
season.ShowDate,
|
season.ShowDate,
|
||||||
season.ShowStartsAt,
|
season.ShowStartsAt,
|
||||||
|
season.WinnersPublishedAt,
|
||||||
|
season.WinnersPublishedByTwitchId,
|
||||||
|
subcategoryTemplates,
|
||||||
categories,
|
categories,
|
||||||
candidates,
|
candidates,
|
||||||
pendingNominations,
|
pendingNominations,
|
||||||
|
pendingNominationGroups,
|
||||||
reviewedNominations,
|
reviewedNominations,
|
||||||
|
settings?.TrackingReviewNotes ?? string.Empty,
|
||||||
|
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||||
resultItems,
|
resultItems,
|
||||||
|
votingWorkspace,
|
||||||
clipSubmissions));
|
clipSubmissions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record AdminVotingEntryRow(
|
||||||
|
int BallotId,
|
||||||
|
int CategoryId,
|
||||||
|
int CandidateId);
|
||||||
|
|
||||||
|
private sealed record AdminCandidateRow(
|
||||||
|
int Id,
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string Platform,
|
||||||
|
int NominationTally,
|
||||||
|
string AcceptanceStatus,
|
||||||
|
string? AcceptanceNote,
|
||||||
|
string? ClipCompilationUrl,
|
||||||
|
string? ClipCompilationTitle,
|
||||||
|
string? ClipCompilationPlatform,
|
||||||
|
string ClipEmbedStatus);
|
||||||
|
|
||||||
|
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 AdminCandidateItemDto[] BuildCandidateItems(
|
||||||
|
AdminCandidateRow[] candidateRows,
|
||||||
|
AdminNominationRow[] pendingNominationRows,
|
||||||
|
AdminNominationRow[] reviewedNominationRows,
|
||||||
|
AdminVotingEntryRow[] votingEntryRows)
|
||||||
|
{
|
||||||
|
var allNominationRows = pendingNominationRows
|
||||||
|
.Concat(reviewedNominationRows)
|
||||||
|
.ToArray();
|
||||||
|
var voteCountByCandidate = votingEntryRows
|
||||||
|
.GroupBy(item => item.CandidateId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count());
|
||||||
|
|
||||||
|
return candidateRows
|
||||||
|
.Select(item => new AdminCandidateItemDto(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.DisplayName,
|
||||||
|
item.ChannelSlug,
|
||||||
|
item.Platform,
|
||||||
|
ResolveCandidateAvgViewers(item, allNominationRows),
|
||||||
|
voteCountByCandidate.GetValueOrDefault(item.Id, 0),
|
||||||
|
item.NominationTally,
|
||||||
|
item.AcceptanceStatus,
|
||||||
|
item.AcceptanceNote,
|
||||||
|
item.ClipCompilationUrl,
|
||||||
|
item.ClipCompilationTitle,
|
||||||
|
item.ClipCompilationPlatform,
|
||||||
|
item.ClipEmbedStatus))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ResolveCandidateAvgViewers(AdminCandidateRow candidate, AdminNominationRow[] nominationRows)
|
||||||
|
{
|
||||||
|
var directMatch = nominationRows
|
||||||
|
.Where(item => item.CandidateId == candidate.Id && item.AvgViewers.HasValue)
|
||||||
|
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
|
||||||
|
.Select(item => item.AvgViewers)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (directMatch.HasValue)
|
||||||
|
{
|
||||||
|
return directMatch.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.StreamerIdentityId.HasValue)
|
||||||
|
{
|
||||||
|
var identityMatch = nominationRows
|
||||||
|
.Where(item => item.StreamerIdentityId == candidate.StreamerIdentityId && item.AvgViewers.HasValue)
|
||||||
|
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
|
||||||
|
.Select(item => item.AvgViewers)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (identityMatch.HasValue)
|
||||||
|
{
|
||||||
|
return identityMatch.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedChannel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedChannel))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nominationRows
|
||||||
|
.Where(item =>
|
||||||
|
item.AvgViewers.HasValue
|
||||||
|
&& string.Equals(item.ResolvedChannel?.Trim().TrimStart('@'), normalizedChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
|
||||||
|
.Select(item => item.AvgViewers)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Name,
|
||||||
item.CurrentPhase,
|
item.CurrentPhase,
|
||||||
item.IsCurrent,
|
item.IsCurrent,
|
||||||
item.Categories.Count))
|
item.IsDemo,
|
||||||
|
item.Categories.Count,
|
||||||
|
item.WinnersPublishedAt,
|
||||||
|
item.WinnersPublishedByTwitchId))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
return Results.Ok(seasons);
|
return Results.Ok(seasons);
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
.WithName("DeleteAdminCategory")
|
.WithName("DeleteAdminCategory")
|
||||||
.WithOpenApi();
|
.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)
|
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
.WithName("CreateAdminCandidate")
|
.WithName("CreateAdminCandidate")
|
||||||
@@ -44,6 +60,10 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
.WithName("UpdateAdminCandidate")
|
.WithName("UpdateAdminCandidate")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
group.MapGet("/candidates/{candidateId:int}/delete-preview", GetCandidateDeletePreview)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
|
.WithName("GetAdminCandidateDeletePreview")
|
||||||
|
.WithOpenApi();
|
||||||
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate)
|
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
.WithName("DeleteAdminCandidate")
|
.WithName("DeleteAdminCandidate")
|
||||||
@@ -56,6 +76,22 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||||
.WithName("DeleteAdminResult")
|
.WithName("DeleteAdminResult")
|
||||||
.WithOpenApi();
|
.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;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Backend.Common;
|
using Backend.Common;
|
||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
@@ -14,6 +16,40 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
private const int MaxCandidateDisplayNameLength = 120;
|
private const int MaxCandidateDisplayNameLength = 120;
|
||||||
private const int MaxCandidateChannelSlugLength = 120;
|
private const int MaxCandidateChannelSlugLength = 120;
|
||||||
private const int MaxCandidatePlatformLength = 60;
|
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)
|
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||||
{
|
{
|
||||||
@@ -79,11 +115,6 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizeSeasonStreamUrl(string? showStreamUrl)
|
|
||||||
{
|
|
||||||
return SeasonMappings.NormalizeSeasonStreamUrl(showStreamUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsKnownSeasonPhase(string? currentPhase)
|
private static bool IsKnownSeasonPhase(string? currentPhase)
|
||||||
{
|
{
|
||||||
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
@@ -127,11 +158,101 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
var issueList = issues.ToArray();
|
var issueList = issues.ToArray();
|
||||||
return Results.BadRequest(new
|
return Results.BadRequest(new
|
||||||
{
|
{
|
||||||
message = $"Public-/Archiv-Readiness blockiert: {string.Join(" ", issueList)}",
|
message = $"Landingpage-Freigabe blockiert: {string.Join(" ", issueList)}",
|
||||||
issues = 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(
|
private static string[] BuildNewSeasonReadinessIssues(
|
||||||
string currentPhase,
|
string currentPhase,
|
||||||
bool isCurrent,
|
bool isCurrent,
|
||||||
@@ -171,6 +292,14 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
bool isCurrent,
|
bool isCurrent,
|
||||||
CancellationToken cancellationToken)
|
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 phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||||
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||||
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||||
@@ -179,6 +308,11 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return [];
|
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
|
var categoryIds = await db.Categories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -193,21 +327,57 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||||
{
|
{
|
||||||
var categoriesWithCandidates = await db.Candidates
|
var candidateSnapshots = await db.Candidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.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)
|
.Select(item => item.CategoryId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.CountAsync(cancellationToken);
|
.Count();
|
||||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||||
if (emptyCategories > 0)
|
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 (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
|
var categoriesWithResults = await db.Results
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
@@ -219,11 +389,141 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
{
|
{
|
||||||
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
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();
|
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)
|
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
||||||
{
|
{
|
||||||
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
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." });
|
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;
|
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." });
|
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;
|
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." });
|
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 =>
|
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
|
||||||
item.SeasonId == seasonId
|
item.SeasonId == seasonId
|
||||||
&& item.CategoryId == request.CategoryId);
|
&& item.CategoryId == request.CategoryId);
|
||||||
@@ -57,6 +95,13 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
existingResult.CategoryName = category.Name;
|
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(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
"result.set",
|
"result.set",
|
||||||
@@ -69,6 +114,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
categoryId = request.CategoryId,
|
categoryId = request.CategoryId,
|
||||||
candidateId = request.CandidateId,
|
candidateId = request.CandidateId,
|
||||||
candidateName = candidate.DisplayName,
|
candidateName = candidate.DisplayName,
|
||||||
|
unpublishedWinners = wasPublished,
|
||||||
},
|
},
|
||||||
RequestMetadataReader.Read(context));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
@@ -93,12 +139,20 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
var result = await db.Results
|
var result = await db.Results
|
||||||
.Include(item => item.Category)
|
.Include(item => item.Category)
|
||||||
.Include(item => item.Candidate)
|
.Include(item => item.Candidate)
|
||||||
|
.Include(item => item.Season)
|
||||||
.FirstOrDefaultAsync(item => item.Id == resultId);
|
.FirstOrDefaultAsync(item => item.Id == resultId);
|
||||||
if (result is null)
|
if (result is null)
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
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);
|
db.Results.Remove(result);
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -112,10 +166,98 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
result.CategoryId,
|
result.CategoryId,
|
||||||
result.CandidateId,
|
result.CandidateId,
|
||||||
candidateName = result.Candidate.DisplayName,
|
candidateName = result.Candidate.DisplayName,
|
||||||
|
unpublishedWinners = wasPublished,
|
||||||
},
|
},
|
||||||
RequestMetadataReader.Read(context));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(new { deleted = true, resultId });
|
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." });
|
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl);
|
|
||||||
|
|
||||||
var wasCurrent = season.IsCurrent;
|
var wasCurrent = season.IsCurrent;
|
||||||
var previousPhase = season.CurrentPhase;
|
var previousPhase = season.CurrentPhase;
|
||||||
var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase);
|
var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase);
|
||||||
@@ -58,7 +56,6 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
season.Year = request.Year;
|
season.Year = request.Year;
|
||||||
season.Name = request.Name.Trim();
|
season.Name = request.Name.Trim();
|
||||||
season.ShowStreamUrl = showStreamUrl;
|
|
||||||
season.CurrentPhase = request.CurrentPhase.Trim();
|
season.CurrentPhase = request.CurrentPhase.Trim();
|
||||||
season.IsCommunityOnly = request.IsCommunityOnly;
|
season.IsCommunityOnly = request.IsCommunityOnly;
|
||||||
season.NominationStartsAt = request.NominationStartsAt;
|
season.NominationStartsAt = request.NominationStartsAt;
|
||||||
@@ -98,7 +95,6 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
{
|
{
|
||||||
request.Year,
|
request.Year,
|
||||||
request.Name,
|
request.Name,
|
||||||
showStreamUrl,
|
|
||||||
previousPhase,
|
previousPhase,
|
||||||
request.CurrentPhase,
|
request.CurrentPhase,
|
||||||
wasCurrent,
|
wasCurrent,
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
{
|
{
|
||||||
private const string FallbackMaintenanceTitle = "Sternenpause";
|
private const string FallbackMaintenanceTitle = "Sternenpause";
|
||||||
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||||
|
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||||
|
private const string DefaultHostImageUrl = "/assets/amaterasu2sei_2.png";
|
||||||
|
private const int MaxHostImageBytes = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> AllowedHostImageContentTypes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["image/png"] = ".png",
|
||||||
|
["image/jpeg"] = ".jpg",
|
||||||
|
["image/webp"] = ".webp",
|
||||||
|
};
|
||||||
|
|
||||||
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
@@ -24,6 +34,10 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
|
||||||
.WithName("UpdateAdminSiteSettings")
|
.WithName("UpdateAdminSiteSettings")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
group.MapPost("/site-settings/host-image", UploadHostImage)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
|
||||||
|
.WithName("UploadAdminHostImage")
|
||||||
|
.WithOpenApi();
|
||||||
group.MapGet("/operational-settings", GetOperationalSettings)
|
group.MapGet("/operational-settings", GetOperationalSettings)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
.WithName("GetAdminOperationalSettings")
|
.WithName("GetAdminOperationalSettings")
|
||||||
@@ -32,6 +46,30 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
.WithName("UpdateAdminOperationalSettings")
|
.WithName("UpdateAdminOperationalSettings")
|
||||||
.WithOpenApi();
|
.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;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,30 +81,91 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new AdminSiteSettingsResponse(
|
return Results.Ok(MapSiteSettingsResponse(settings));
|
||||||
settings.HostDisplayName,
|
}
|
||||||
settings.HostTagline,
|
|
||||||
settings.NewsletterUrl,
|
private static async Task<IResult> UploadHostImage(
|
||||||
settings.PrivacyEmail,
|
HttpContext context,
|
||||||
settings.PrivacyPolicyContent,
|
AwardsDbContext db,
|
||||||
settings.PrivacyPolicyUpdatedBy,
|
IAdminAuditService adminAuditService)
|
||||||
settings.PrivacyPolicyUpdatedAt,
|
{
|
||||||
settings.ImprintUrl,
|
if (!context.Request.HasFormContentType)
|
||||||
settings.ImprintContent,
|
{
|
||||||
settings.ContactUrl,
|
return Results.BadRequest(new { message = "Bitte ein Bild als Formular-Upload senden." });
|
||||||
settings.ContactContent,
|
}
|
||||||
settings.SponsorsUrl,
|
|
||||||
settings.SponsorsContent,
|
var form = await context.Request.ReadFormAsync(context.RequestAborted);
|
||||||
SeasonMappings.ReadSocialLinks(settings),
|
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
|
||||||
SeasonMappings.ReadFaqItems(settings)));
|
if (file is null || file.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte ein Hostbild auswählen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.Length > MaxHostImageBytes)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Hostbild ist zu groß. Maximal erlaubt sind 8 MB." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AllowedHostImageContentTypes.TryGetValue(file.ContentType, out var expectedExtension))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte PNG, JPG oder WebP hochladen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(file.FileName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(extension)
|
||||||
|
&& !string.Equals(extension, expectedExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !(string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Dateiendung und Bildtyp passen nicht zusammen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
using var memory = new MemoryStream((int)file.Length);
|
||||||
|
await stream.CopyToAsync(memory, context.RequestAborted);
|
||||||
|
|
||||||
|
settings.HostImageData = memory.ToArray();
|
||||||
|
settings.HostImageContentType = file.ContentType;
|
||||||
|
settings.HostImageUpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"site-settings.host-image.upload",
|
||||||
|
"site-settings",
|
||||||
|
settings.Id.ToString(),
|
||||||
|
"Landingpage-Hostbild wurde aktualisiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
fileName = file.FileName,
|
||||||
|
fileSize = file.Length,
|
||||||
|
file.ContentType,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(MapSiteSettingsResponse(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSiteSettings(
|
private static async Task<IResult> UpdateSiteSettings(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
UpdateSiteSettingsRequest request,
|
UpdateSiteSettingsRequest? request,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IAdminAuditService adminAuditService)
|
IAdminAuditService adminAuditService)
|
||||||
{
|
{
|
||||||
|
var requestValidationError = ValidateSiteSettingsRequest(request);
|
||||||
|
if (requestValidationError is not null)
|
||||||
|
{
|
||||||
|
return requestValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = request!;
|
||||||
var session = AdminEndpointConventions.CurrentSession(context);
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
|
||||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
@@ -75,17 +174,20 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks);
|
var urlValidationError = NormalizeSiteSettingsUrls(validatedRequest, out var normalizedUrls, out var socialLinks);
|
||||||
if (urlValidationError is not null)
|
if (urlValidationError is not null)
|
||||||
{
|
{
|
||||||
return urlValidationError;
|
return urlValidationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
settings.HostDisplayName = validatedRequest.HostDisplayName.Trim();
|
||||||
settings.HostTagline = request.HostTagline.Trim();
|
settings.HostTagline = validatedRequest.HostTagline.Trim();
|
||||||
|
settings.HostArtistName = validatedRequest.HostArtistName.Trim();
|
||||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
||||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
||||||
|
settings.PrivacyEmail = validatedRequest.PrivacyEmail.Trim();
|
||||||
|
var trimmedPrivacyContent = validatedRequest.PrivacyPolicyContent.Trim();
|
||||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||||
settings.PrivacyPolicyContent = trimmedPrivacyContent;
|
settings.PrivacyPolicyContent = trimmedPrivacyContent;
|
||||||
if (privacyChanged)
|
if (privacyChanged)
|
||||||
@@ -95,13 +197,32 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.ImprintUrl = normalizedUrls.ImprintUrl;
|
settings.ImprintUrl = normalizedUrls.ImprintUrl;
|
||||||
settings.ImprintContent = request.ImprintContent.Trim();
|
settings.ImprintContent = validatedRequest.ImprintContent.Trim();
|
||||||
settings.ContactUrl = normalizedUrls.ContactUrl;
|
settings.ContactUrl = normalizedUrls.ContactUrl;
|
||||||
settings.ContactContent = request.ContactContent.Trim();
|
settings.ContactContent = validatedRequest.ContactContent.Trim();
|
||||||
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
||||||
settings.SponsorsContent = request.SponsorsContent.Trim();
|
settings.SponsorsContent = validatedRequest.SponsorsContent.Trim();
|
||||||
|
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
|
||||||
|
settings.ShowactsContent = validatedRequest.ShowactsContent.Trim();
|
||||||
|
settings.StreamBannerEyebrow = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerEyebrow);
|
||||||
|
settings.StreamBannerTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerTitle);
|
||||||
|
settings.StreamBannerText = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerText);
|
||||||
|
settings.StreamBannerLiveButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerLiveButtonLabel);
|
||||||
|
settings.StreamBannerLiveButtonUrl = normalizedUrls.StreamBannerLiveButtonUrl;
|
||||||
|
settings.StreamBannerLockedButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerLockedButtonLabel);
|
||||||
|
settings.StreamBannerUseCompletedContent = validatedRequest.StreamBannerUseCompletedContent;
|
||||||
|
settings.StreamBannerCompletedEyebrow = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedEyebrow);
|
||||||
|
settings.StreamBannerCompletedTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedTitle);
|
||||||
|
settings.StreamBannerCompletedText = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedText);
|
||||||
|
settings.StreamBannerCompletedButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedButtonLabel);
|
||||||
|
settings.StreamBannerCompletedButtonUrl = normalizedUrls.StreamBannerCompletedButtonUrl;
|
||||||
|
settings.AwardsSectionTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.AwardsSectionTitle);
|
||||||
|
settings.AwardsSectionDescription = SeasonMappings.NormalizePlainTextContent(validatedRequest.AwardsSectionDescription);
|
||||||
|
settings.SubcategoriesSectionTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.SubcategoriesSectionTitle);
|
||||||
|
settings.SubcategoriesSectionDescription = SeasonMappings.NormalizePlainTextContent(validatedRequest.SubcategoriesSectionDescription);
|
||||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
settings.FaqJson = JsonSerializer.Serialize(validatedRequest.Faq);
|
||||||
|
settings.ShowactFormSchemaJson = validatedRequest.ShowactFormSchemaJson ?? "[]";
|
||||||
|
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -114,7 +235,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.HostDisplayName,
|
settings.HostDisplayName,
|
||||||
privacyChanged,
|
privacyChanged,
|
||||||
socialLinkCount = socialLinks.Length,
|
socialLinkCount = socialLinks.Length,
|
||||||
faqCount = request.Faq?.Length ?? 0,
|
faqCount = validatedRequest.Faq.Length,
|
||||||
},
|
},
|
||||||
RequestMetadataReader.Read(context));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
@@ -122,6 +243,61 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.Ok(new { saved = true });
|
return Results.Ok(new { saved = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AdminSiteSettingsResponse MapSiteSettingsResponse(SiteSettings settings)
|
||||||
|
{
|
||||||
|
return new AdminSiteSettingsResponse(
|
||||||
|
settings.HostDisplayName,
|
||||||
|
settings.HostTagline,
|
||||||
|
settings.HostArtistName,
|
||||||
|
BuildHostImageUrl(settings),
|
||||||
|
settings.NewsletterUrl,
|
||||||
|
settings.ShareXUrl,
|
||||||
|
settings.ShareDiscordUrl,
|
||||||
|
settings.PrivacyEmail,
|
||||||
|
settings.PrivacyPolicyContent,
|
||||||
|
settings.PrivacyPolicyUpdatedBy,
|
||||||
|
settings.PrivacyPolicyUpdatedAt,
|
||||||
|
settings.ImprintUrl,
|
||||||
|
settings.ImprintContent,
|
||||||
|
settings.ContactUrl,
|
||||||
|
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),
|
||||||
|
settings.ShowactFormSchemaJson ?? "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildHostImageUrl(SiteSettings settings)
|
||||||
|
{
|
||||||
|
if (settings.HostImageData is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
return DefaultHostImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
?? settings.HostImageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
return $"/api/public/host-image?v={version}";
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult? NormalizeSiteSettingsUrls(
|
private static IResult? NormalizeSiteSettingsUrls(
|
||||||
UpdateSiteSettingsRequest request,
|
UpdateSiteSettingsRequest request,
|
||||||
out PublicSiteUrlSettings normalizedUrls,
|
out PublicSiteUrlSettings normalizedUrls,
|
||||||
@@ -131,9 +307,14 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
socialLinks = [];
|
socialLinks = [];
|
||||||
|
|
||||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
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.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, 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 });
|
return Results.BadRequest(new { message = errorMessage });
|
||||||
}
|
}
|
||||||
@@ -141,9 +322,14 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
normalizedUrls = new PublicSiteUrlSettings
|
normalizedUrls = new PublicSiteUrlSettings
|
||||||
{
|
{
|
||||||
NewsletterUrl = newsletterUrl,
|
NewsletterUrl = newsletterUrl,
|
||||||
|
ShareXUrl = shareXUrl,
|
||||||
|
ShareDiscordUrl = shareDiscordUrl,
|
||||||
ImprintUrl = imprintUrl,
|
ImprintUrl = imprintUrl,
|
||||||
ContactUrl = contactUrl,
|
ContactUrl = contactUrl,
|
||||||
SponsorsUrl = sponsorsUrl,
|
SponsorsUrl = sponsorsUrl,
|
||||||
|
ShowactsUrl = showactsUrl,
|
||||||
|
StreamBannerLiveButtonUrl = streamBannerLiveButtonUrl,
|
||||||
|
StreamBannerCompletedButtonUrl = streamBannerCompletedButtonUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
||||||
@@ -161,6 +347,58 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateSiteSettingsRequest(UpdateSiteSettingsRequest? request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Public Site Settings brauchen einen Request-Body." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SocialLinks is null || request.Faq is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Public Site Settings brauchen Social Links und FAQ im Request-Body." });
|
||||||
|
}
|
||||||
|
|
||||||
|
string?[] requiredTextFields =
|
||||||
|
[
|
||||||
|
request.HostDisplayName,
|
||||||
|
request.HostTagline,
|
||||||
|
request.HostArtistName,
|
||||||
|
request.NewsletterUrl,
|
||||||
|
request.ShareXUrl,
|
||||||
|
request.ShareDiscordUrl,
|
||||||
|
request.PrivacyEmail,
|
||||||
|
request.PrivacyPolicyContent,
|
||||||
|
request.ImprintUrl,
|
||||||
|
request.ImprintContent,
|
||||||
|
request.ContactUrl,
|
||||||
|
request.ContactContent,
|
||||||
|
request.SponsorsUrl,
|
||||||
|
request.SponsorsContent,
|
||||||
|
request.ShowactsUrl,
|
||||||
|
request.ShowactsContent,
|
||||||
|
request.StreamBannerEyebrow,
|
||||||
|
request.StreamBannerTitle,
|
||||||
|
request.StreamBannerText,
|
||||||
|
request.StreamBannerLiveButtonLabel,
|
||||||
|
request.StreamBannerLiveButtonUrl,
|
||||||
|
request.StreamBannerLockedButtonLabel,
|
||||||
|
request.StreamBannerCompletedEyebrow,
|
||||||
|
request.StreamBannerCompletedTitle,
|
||||||
|
request.StreamBannerCompletedText,
|
||||||
|
request.StreamBannerCompletedButtonLabel,
|
||||||
|
request.StreamBannerCompletedButtonUrl,
|
||||||
|
request.AwardsSectionTitle,
|
||||||
|
request.AwardsSectionDescription,
|
||||||
|
request.SubcategoriesSectionTitle,
|
||||||
|
request.SubcategoriesSectionDescription,
|
||||||
|
];
|
||||||
|
|
||||||
|
return requiredTextFields.Any(field => field is null)
|
||||||
|
? Results.BadRequest(new { message = "Public Site Settings brauchen alle Pflichtfelder im Request-Body." })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
|
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
|
||||||
{
|
{
|
||||||
normalizedUrl = (value ?? string.Empty).Trim();
|
normalizedUrl = (value ?? string.Empty).Trim();
|
||||||
@@ -184,9 +422,282 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
private sealed class PublicSiteUrlSettings
|
private sealed class PublicSiteUrlSettings
|
||||||
{
|
{
|
||||||
public string NewsletterUrl { 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 ImprintUrl { get; set; } = string.Empty;
|
public string ImprintUrl { get; set; } = string.Empty;
|
||||||
public string ContactUrl { get; set; } = string.Empty;
|
public string ContactUrl { get; set; } = string.Empty;
|
||||||
public string SponsorsUrl { 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)
|
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
|
||||||
@@ -197,7 +708,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||||
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||||
usesDatabaseDemo,
|
usesDatabaseDemo,
|
||||||
@@ -212,6 +723,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
twitchSettings.ClientSecretSet,
|
twitchSettings.ClientSecretSet,
|
||||||
twitchSettings.RedirectUri,
|
twitchSettings.RedirectUri,
|
||||||
twitchSettings.Scope,
|
twitchSettings.Scope,
|
||||||
|
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||||
settings.MaintenanceModeEnabled,
|
settings.MaintenanceModeEnabled,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||||
@@ -248,6 +760,12 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
||||||
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
||||||
var twitchScope = request.TwitchScope.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)
|
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||||
|
|
||||||
@@ -307,6 +825,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.TwitchClientSecret,
|
settings.TwitchClientSecret,
|
||||||
settings.TwitchRedirectUri,
|
settings.TwitchRedirectUri,
|
||||||
settings.TwitchScope);
|
settings.TwitchScope);
|
||||||
|
settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours;
|
||||||
|
|
||||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||||
? newPassword
|
? newPassword
|
||||||
@@ -339,11 +858,12 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
"operational-settings.update",
|
"operational-settings.update",
|
||||||
"site-settings",
|
"site-settings",
|
||||||
settings.Id.ToString(),
|
settings.Id.ToString(),
|
||||||
"Demo-Zugang und Wartungsmodus wurden aktualisiert.",
|
"Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
settings.DemoLoginEnabled,
|
settings.DemoLoginEnabled,
|
||||||
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist),
|
||||||
|
settings.SessionIdleTimeoutHours,
|
||||||
settings.MaintenanceModeEnabled,
|
settings.MaintenanceModeEnabled,
|
||||||
changes,
|
changes,
|
||||||
},
|
},
|
||||||
@@ -438,6 +958,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
HasEffectiveTwitchClientSecret(settings, configuration),
|
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||||
settings.TwitchRedirectUri,
|
settings.TwitchRedirectUri,
|
||||||
settings.TwitchScope,
|
settings.TwitchScope,
|
||||||
|
UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours),
|
||||||
settings.MaintenanceModeEnabled,
|
settings.MaintenanceModeEnabled,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||||
@@ -459,6 +980,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
||||||
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
||||||
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
||||||
|
AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours);
|
||||||
|
|
||||||
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
||||||
{
|
{
|
||||||
@@ -536,6 +1058,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
bool TwitchClientSecretSet,
|
bool TwitchClientSecretSet,
|
||||||
string TwitchRedirectUri,
|
string TwitchRedirectUri,
|
||||||
string TwitchScope,
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
@@ -546,4 +1069,136 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
string RedirectUri,
|
string RedirectUri,
|
||||||
string Scope,
|
string Scope,
|
||||||
bool Configured);
|
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 =
|
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
|
||||||
[
|
[
|
||||||
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "/admin/dashboard", true),
|
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "Betrieb", "/admin/dashboard", true),
|
||||||
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "/admin/years", false),
|
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "Betrieb", "/admin/nominations", false),
|
||||||
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "/admin/nominations", false),
|
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "Awards", "/admin/years", false),
|
||||||
new(AdminPermissionCatalog.Categories, "Kategorien", "Kategorien und Limits verwalten.", "/admin/categories", false),
|
new(AdminPermissionCatalog.Categories, "Kategorien", "Hauptkategorien, Unterkategorien und Limits verwalten.", "Awards", "/admin/categories", false),
|
||||||
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis bearbeiten.", "/admin/candidates", false),
|
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis, Clips und Annahmestatus pflegen.", "Awards", "/admin/candidates", false),
|
||||||
new(AdminPermissionCatalog.Clips, "Clips", "Clip-Einreichungen prüfen.", "/admin/clips", false),
|
new(AdminPermissionCatalog.Clips, "Clips", "Optionale Clip-Einreichungen prüfen.", "Awards", "/admin/clips", false),
|
||||||
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "/admin/risk", true),
|
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Footer, Showacts und öffentliche Inhalte pflegen.", "Landingpage", "/admin/content", false),
|
||||||
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "/admin/users-logs", true),
|
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "Kontrolle", "/admin/risk", true),
|
||||||
new(AdminPermissionCatalog.Analytics, "Analytics", "Metriken und Rankings lesen.", "/admin/analytics", true),
|
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "Kontrolle", "/admin/users-logs", true),
|
||||||
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "/admin/winners", false),
|
new(AdminPermissionCatalog.Analytics, "Analytics", "Jahresmetriken und Überblick lesen.", "Auswertung", "/admin/analytics", true),
|
||||||
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Datenschutz und öffentliche Inhalte pflegen.", "/admin/content", false),
|
new(AdminPermissionCatalog.Voting, "Voting", "Stimmenlage und Gewinner-Vorbereitung sehen.", "Auswertung", "/admin/voting", true),
|
||||||
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang und Wartung sehen.", "/admin/settings", true),
|
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "Auswertung", "/admin/winners", false),
|
||||||
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "/admin/team", 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 =
|
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(
|
private static async Task<IResult> DemoLogin(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
|
IHostEnvironment environment,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
DemoLoginRequest request,
|
DemoLoginRequest request,
|
||||||
@@ -23,11 +24,16 @@ public static partial class AuthEndpoints
|
|||||||
var password = request.Password ?? string.Empty;
|
var password = request.Password ?? string.Empty;
|
||||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
var databaseDemoConfigured = settings is not null
|
var databaseDemoConfigured = settings is not null
|
||||||
&& (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings));
|
&& settings.DemoLoginManagedByDatabase;
|
||||||
|
|
||||||
string twitchUserId;
|
string twitchUserId;
|
||||||
string displayName;
|
string displayName;
|
||||||
bool credentialsMatch;
|
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)
|
if (databaseDemoConfigured && settings is not null)
|
||||||
{
|
{
|
||||||
@@ -53,6 +59,25 @@ public static partial class AuthEndpoints
|
|||||||
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||||
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||||
displayName = settings.DemoLoginDisplayName.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
|
else
|
||||||
{
|
{
|
||||||
@@ -61,16 +86,10 @@ public static partial class AuthEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var configuredLogin = ReadDemoLoginIdentifier(configuration);
|
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||||
var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||||
var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||||
twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||||
displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(configuredLogin)
|
|
||||||
|| string.IsNullOrWhiteSpace(configuredPassword)
|
|
||||||
|| string.IsNullOrWhiteSpace(twitchUserId)
|
|
||||||
|| string.IsNullOrWhiteSpace(displayName))
|
|
||||||
{
|
{
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
new { message = "Demo login is not fully configured." },
|
new { message = "Demo login is not fully configured." },
|
||||||
@@ -79,13 +98,13 @@ public static partial class AuthEndpoints
|
|||||||
|
|
||||||
credentialsMatch = LoginMatchesIdentifier(
|
credentialsMatch = LoginMatchesIdentifier(
|
||||||
login,
|
login,
|
||||||
configuredLogin,
|
fallbackConfiguredLogin,
|
||||||
configuredEmail,
|
fallbackConfiguredEmail,
|
||||||
twitchUserId,
|
fallbackConfiguredTwitchUserId,
|
||||||
displayName)
|
fallbackConfiguredDisplayName)
|
||||||
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword);
|
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||||
twitchUserId = twitchUserId.Trim();
|
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||||
displayName = displayName.Trim();
|
displayName = fallbackConfiguredDisplayName.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!credentialsMatch)
|
if (!credentialsMatch)
|
||||||
@@ -137,7 +156,7 @@ public static partial class AuthEndpoints
|
|||||||
await db.SaveChangesAsync(context.RequestAborted);
|
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)
|
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||||
|
|||||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
|||||||
await db.SaveChangesAsync(context.RequestAborted);
|
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.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)
|
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(
|
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
UserSession session,
|
UserSession session,
|
||||||
bool mustChangePassword = false,
|
bool mustChangePassword = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -43,12 +44,14 @@ public static partial class AuthEndpoints
|
|||||||
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||||
var sessionRole = teamMember?.Role ?? session.Role;
|
var sessionRole = teamMember?.Role ?? session.Role;
|
||||||
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||||
|
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
|
||||||
return new(
|
return new(
|
||||||
session.SessionToken,
|
session.SessionToken,
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
teamMember?.DisplayName ?? session.DisplayName,
|
teamMember?.DisplayName ?? session.DisplayName,
|
||||||
AdminRoles.Normalize(sessionRole),
|
AdminRoles.Normalize(sessionRole),
|
||||||
permissionKeys,
|
permissionKeys,
|
||||||
|
sessionIdleTimeoutHours,
|
||||||
teamMember?.MustChangePassword ?? mustChangePassword,
|
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||||
teamMember?.Login,
|
teamMember?.Login,
|
||||||
teamMember?.BoundTwitchUserId,
|
teamMember?.BoundTwitchUserId,
|
||||||
|
|||||||
@@ -15,16 +15,21 @@ public static partial class AuthEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> TeamLogin(
|
private static async Task<IResult> TeamLogin(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
TeamLoginRequest request,
|
TeamLoginRequest? request,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IUserSessionService userSessionService)
|
IUserSessionService userSessionService)
|
||||||
{
|
{
|
||||||
var login = NormalizeTeamLogin(request.Login);
|
if (request is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Login und Passwort sind erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var login = NormalizeTeamLogin(request.Login ?? string.Empty);
|
||||||
var password = request.Password ?? string.Empty;
|
var password = request.Password ?? string.Empty;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
|
||||||
{
|
{
|
||||||
return Results.Unauthorized();
|
return Results.BadRequest(new { message = "Login und Passwort sind erforderlich." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
|
||||||
@@ -44,7 +49,7 @@ public static partial class AuthEndpoints
|
|||||||
context.RequestAborted);
|
context.RequestAborted);
|
||||||
|
|
||||||
await db.SaveChangesAsync(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(
|
private static async Task<IResult> ChangePassword(
|
||||||
@@ -92,7 +97,7 @@ public static partial class AuthEndpoints
|
|||||||
session.Role = AdminRoles.Normalize(member.Role);
|
session.Role = AdminRoles.Normalize(member.Role);
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
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) =>
|
private static string BuildTeamSessionId(string login) =>
|
||||||
@@ -120,8 +125,8 @@ public static partial class AuthEndpoints
|
|||||||
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
|
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizeTeamLogin(string value) =>
|
private static string NormalizeTeamLogin(string? value) =>
|
||||||
value.Trim().TrimStart('@').ToLowerInvariant();
|
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|
||||||
private static string NormalizeTwitchUserId(string? value) =>
|
private static string NormalizeTwitchUserId(string? value) =>
|
||||||
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ public static partial class AuthEndpoints
|
|||||||
return Results.Ok(new TwitchBindingDisconnectResponse(
|
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||||
@@ -265,7 +265,7 @@ public static partial class AuthEndpoints
|
|||||||
currentSessionUsesBoundTwitch,
|
currentSessionUsesBoundTwitch,
|
||||||
currentSessionUsesBoundTwitch
|
currentSessionUsesBoundTwitch
|
||||||
? null
|
? null
|
||||||
: await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted)));
|
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||||
|
|||||||
@@ -11,13 +11,20 @@ public static partial class PublicEndpoints
|
|||||||
{
|
{
|
||||||
private static async Task<IResult> CreateClip(
|
private static async Task<IResult> CreateClip(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
CreateClipRequest request,
|
CreateClipRequest? request,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IUserSessionService userSessionService,
|
IUserSessionService userSessionService,
|
||||||
IRiskFlagService riskFlagService,
|
IRiskFlagService riskFlagService,
|
||||||
IRiskRuleService riskRuleService)
|
IRiskRuleService riskRuleService)
|
||||||
{
|
{
|
||||||
if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl))
|
var validationError = ValidateCreateClipRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = request!;
|
||||||
|
if (!TryNormalizeExternalUrl(validatedRequest.ClipUrl, out var clipUrl))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
|
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
|
||||||
}
|
}
|
||||||
@@ -28,7 +35,23 @@ public static partial class PublicEndpoints
|
|||||||
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
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 == validatedRequest.Year);
|
||||||
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||||
if (clipSeasonResolution.Result is not null)
|
if (clipSeasonResolution.Result is not null)
|
||||||
{
|
{
|
||||||
@@ -37,26 +60,26 @@ public static partial class PublicEndpoints
|
|||||||
|
|
||||||
season = clipSeasonResolution.Season!;
|
season = clipSeasonResolution.Season!;
|
||||||
|
|
||||||
var selectedCandidate = request.CandidateId is int candidateId
|
var selectedCandidate = validatedRequest.CandidateId is int candidateId
|
||||||
? await db.Candidates
|
? await db.Candidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
|
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
|
||||||
: null;
|
: null;
|
||||||
if (request.CandidateId is not null && selectedCandidate is null)
|
if (validatedRequest.CandidateId is not null && selectedCandidate is null)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
|
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.CategoryId is int requestedCategoryId
|
if (validatedRequest.CategoryId is int requestedCategoryId
|
||||||
&& selectedCandidate is not null
|
&& selectedCandidate is not null
|
||||||
&& selectedCandidate.CategoryId != requestedCategoryId)
|
&& selectedCandidate.CategoryId != requestedCategoryId)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedCategoryId = request.CategoryId ?? selectedCandidate?.CategoryId;
|
var resolvedCategoryId = validatedRequest.CategoryId ?? selectedCandidate?.CategoryId;
|
||||||
var normalizedTitle = request.Title?.Trim() ?? string.Empty;
|
var normalizedTitle = validatedRequest.Title?.Trim() ?? string.Empty;
|
||||||
var submittedCreator = request.Creator?.Trim();
|
var submittedCreator = validatedRequest.Creator?.Trim();
|
||||||
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
|
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
|
||||||
? selectedCandidate?.DisplayName ?? string.Empty
|
? selectedCandidate?.DisplayName ?? string.Empty
|
||||||
: submittedCreator;
|
: submittedCreator;
|
||||||
@@ -80,7 +103,7 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
|
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
|
||||||
if (submitterIdResult.Result is not null)
|
if (submitterIdResult.Result is not null)
|
||||||
{
|
{
|
||||||
return submitterIdResult.Result;
|
return submitterIdResult.Result;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Backend.Common;
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
using Backend.Domain;
|
using Backend.Domain;
|
||||||
using Backend.Services;
|
using Backend.Services;
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
using Microsoft.AspNetCore.Http.Extensions;
|
||||||
@@ -205,4 +206,74 @@ public static partial class PublicEndpoints
|
|||||||
"show" => "show",
|
"show" => "show",
|
||||||
_ => "current",
|
_ => "current",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static IResult? ValidateCreateNominationRequest(CreateNominationRequest? request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A nomination request body is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Year <= 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A valid season year is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((request.Nominations?.Length ?? 0) == 0 && (request.Nominees?.Length ?? 0) == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateCreateVoteRequest(CreateVoteRequest? request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A vote request body is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SeasonId <= 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A valid season id is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Entries is null || request.Entries.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "At least one vote entry is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Entries.Any(item => item.CategoryId <= 0 || item.CandidateId <= 0))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Each vote entry requires a valid category and candidate." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateCreateClipRequest(CreateClipRequest? request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A clip request body is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Year <= 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A valid season year is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.CategoryId is <= 0 || request.CandidateId is <= 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Category and candidate ids must be positive when provided." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ClipUrl))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public static partial class PublicEndpoints
|
|||||||
.WithName("GetSiteStatus")
|
.WithName("GetSiteStatus")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/host-image", GetHostImage)
|
||||||
|
.WithName("GetPublicHostImage")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
|
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
|
||||||
.WithName("GetSeasonCategories")
|
.WithName("GetSeasonCategories")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
@@ -22,6 +26,10 @@ public static partial class PublicEndpoints
|
|||||||
.WithName("GetWinnerArchive")
|
.WithName("GetWinnerArchive")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/seasons/{year:int}/sponsors", GetSponsors)
|
||||||
|
.WithName("GetPublicSponsors")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
||||||
.WithName("GetUserParticipation")
|
.WithName("GetUserParticipation")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
@@ -41,6 +49,11 @@ public static partial class PublicEndpoints
|
|||||||
.WithName("CreateClip")
|
.WithName("CreateClip")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/showacts", CreateShowactApplication)
|
||||||
|
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||||
|
.WithName("CreateShowactApplication")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
return app;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetHostImage(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Id == 1)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.HostImageData,
|
||||||
|
item.HostImageContentType,
|
||||||
|
item.HostImageUpdatedAt,
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (settings?.HostImageData is not { Length: > 0 } imageData
|
||||||
|
|| string.IsNullOrWhiteSpace(settings.HostImageContentType))
|
||||||
|
{
|
||||||
|
return Results.Redirect("/assets/amaterasu2sei_2.png", permanent: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entityTag = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
?? imageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
return Results.File(
|
||||||
|
imageData,
|
||||||
|
settings.HostImageContentType,
|
||||||
|
entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"host-{entityTag}\""),
|
||||||
|
lastModified: settings.HostImageUpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,25 @@ public static partial class PublicEndpoints
|
|||||||
{
|
{
|
||||||
private static async Task<IResult> CreateNomination(
|
private static async Task<IResult> CreateNomination(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
CreateNominationRequest request,
|
CreateNominationRequest? request,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IUserSessionService userSessionService,
|
IUserSessionService userSessionService,
|
||||||
IRiskFlagService riskFlagService,
|
IRiskFlagService riskFlagService,
|
||||||
IRiskRuleService riskRuleService)
|
IRiskRuleService riskRuleService,
|
||||||
|
NominationEnrichmentService nominationEnrichmentService)
|
||||||
{
|
{
|
||||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
var validationError = ValidateCreateNominationRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
if (submittedNominations.Length is 0 or > 3)
|
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = request!;
|
||||||
|
var submittedNominations = NormalizeSubmittedNominations(validatedRequest);
|
||||||
|
|
||||||
|
if (submittedNominations.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
||||||
@@ -35,7 +43,7 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var distinctStreamUrls = submittedNominations
|
var distinctStreamUrls = submittedNominations
|
||||||
.Select(item => item.StreamUrl)
|
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -54,22 +62,54 @@ public static partial class PublicEndpoints
|
|||||||
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
|
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var category = await db.Categories
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
.Include(item => item.Season)
|
var linkBlacklist = NominationLinkBlacklistSettings.Read(settings);
|
||||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
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 == validatedRequest.Year);
|
||||||
|
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected season does not exist." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, validatedRequest, 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)
|
if (nominationSeasonResolution.Result is not null)
|
||||||
{
|
{
|
||||||
return nominationSeasonResolution.Result;
|
return nominationSeasonResolution.Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
|
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
|
||||||
if (submitterIdResult.Result is not null)
|
if (submitterIdResult.Result is not null)
|
||||||
{
|
{
|
||||||
return submitterIdResult.Result;
|
return submitterIdResult.Result;
|
||||||
@@ -78,15 +118,16 @@ public static partial class PublicEndpoints
|
|||||||
var submitterId = submitterIdResult.SubmitterId!;
|
var submitterId = submitterIdResult.SubmitterId!;
|
||||||
var requestMetadata = RequestMetadataReader.Read(context);
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||||
item.SeasonId == category.SeasonId
|
item.SeasonId == season.Id
|
||||||
&& item.CategoryId == category.Id
|
&& item.CategoryGroupName == categoryGroupName
|
||||||
&& item.SubmittedByTwitchId == submitterId
|
&& item.SubmittedByTwitchId == submitterId
|
||||||
&& item.Status == "pending");
|
&& item.Status == "pending");
|
||||||
|
|
||||||
var records = submittedNominations.Select(nomination => new Nomination
|
var records = submittedNominations.Select(nomination => new Nomination
|
||||||
{
|
{
|
||||||
SeasonId = category.SeasonId,
|
SeasonId = season.Id,
|
||||||
CategoryId = category.Id,
|
CategoryId = null,
|
||||||
|
CategoryGroupName = categoryGroupName,
|
||||||
SubmittedByTwitchId = submitterId,
|
SubmittedByTwitchId = submitterId,
|
||||||
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||||
@@ -95,6 +136,11 @@ public static partial class PublicEndpoints
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
await db.Nominations.AddRangeAsync(records);
|
await db.Nominations.AddRangeAsync(records);
|
||||||
|
|
||||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||||
@@ -115,21 +161,21 @@ public static partial class PublicEndpoints
|
|||||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||||
{
|
{
|
||||||
await riskFlagService.AddIfMissingAsync(
|
await riskFlagService.AddIfMissingAsync(
|
||||||
category.SeasonId,
|
season.Id,
|
||||||
submitterId,
|
submitterId,
|
||||||
"nomination",
|
"nomination",
|
||||||
"resubmitted_nomination",
|
"resubmitted_nomination",
|
||||||
resubmittedNominationRule.Severity,
|
resubmittedNominationRule.Severity,
|
||||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
||||||
requestMetadata,
|
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);
|
context.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||||
{
|
{
|
||||||
await riskFlagService.AddIfMissingAsync(
|
await riskFlagService.AddIfMissingAsync(
|
||||||
category.SeasonId,
|
season.Id,
|
||||||
submitterId,
|
submitterId,
|
||||||
"nomination",
|
"nomination",
|
||||||
"rapid_nomination_burst",
|
"rapid_nomination_burst",
|
||||||
@@ -141,7 +187,7 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
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);
|
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||||
@@ -180,4 +226,44 @@ public static partial class PublicEndpoints
|
|||||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||||
.ToArray();
|
.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.");
|
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
|
var winnerPreviewRows = await db.Results
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(result => result.Season)
|
.Include(result => result.Season)
|
||||||
.Include(result => result.Candidate)
|
.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)
|
.OrderByDescending(result => result.Season.Year)
|
||||||
.ThenBy(result => result.CategoryName)
|
.ThenBy(result => result.CategoryName)
|
||||||
.Take(8)
|
.Take(8)
|
||||||
.Select(result => new
|
.Select(result => new
|
||||||
{
|
{
|
||||||
Year = result.Season.Year,
|
Year = result.Season.Year,
|
||||||
|
CategoryGroup = result.Category.GroupName,
|
||||||
result.CategoryName,
|
result.CategoryName,
|
||||||
WinnerName = result.Candidate.DisplayName,
|
WinnerName = result.Candidate.DisplayName,
|
||||||
WinnerSlug = result.Candidate.ChannelSlug,
|
WinnerSlug = result.Candidate.ChannelSlug,
|
||||||
WinnerPlatform = result.Candidate.Platform,
|
WinnerPlatform = result.Candidate.Platform,
|
||||||
|
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||||
|
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||||
|
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||||
|
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||||
})
|
})
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var winnerPreviewItems = winnerPreviewRows
|
var winnerPreviewItems = winnerPreviewRows
|
||||||
.Select(result => new WinnerPreviewDto(
|
.Select(result => new WinnerPreviewDto(
|
||||||
result.Year,
|
result.Year,
|
||||||
|
result.CategoryGroup,
|
||||||
result.CategoryName,
|
result.CategoryName,
|
||||||
result.WinnerName,
|
result.WinnerName,
|
||||||
result.WinnerSlug,
|
result.WinnerSlug,
|
||||||
result.WinnerPlatform,
|
result.WinnerPlatform,
|
||||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||||
|
result.ClipUrl,
|
||||||
|
result.ClipTitle,
|
||||||
|
result.ClipPlatform,
|
||||||
|
result.ClipEmbedStatus))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var archiveYearRows = await db.Results
|
var archiveYearRows = await db.Results
|
||||||
.AsNoTracking()
|
.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)
|
.GroupBy(result => result.Season.Year)
|
||||||
.Select(group => new
|
.Select(group => new
|
||||||
{
|
{
|
||||||
@@ -67,21 +89,66 @@ public static partial class PublicEndpoints
|
|||||||
.OrderByDescending(item => item.Year)
|
.OrderByDescending(item => item.Year)
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var archivedWinnerYearRows = await db.ArchivedWinners
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => latestPublishedWinnerYear == null || item.Year < latestPublishedWinnerYear.Value)
|
||||||
|
.GroupBy(item => item.Year)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Year = group.Key,
|
||||||
|
WinnerCount = group.Count(),
|
||||||
|
})
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
var archiveYears = archiveYearRows
|
var archiveYears = archiveYearRows
|
||||||
.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))
|
.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))
|
||||||
|
.Concat(archivedWinnerYearRows.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount)))
|
||||||
|
.GroupBy(item => item.Year)
|
||||||
|
.Select(group => group.Last())
|
||||||
|
.OrderByDescending(item => item.Year)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
var publicCategories = season.Categories
|
var publicCategories = season.Categories
|
||||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||||
.ToArray();
|
.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(
|
var response = new OverviewResponse(
|
||||||
season.Id,
|
season.Id,
|
||||||
season.Year,
|
season.Year,
|
||||||
season.Name,
|
season.Name,
|
||||||
season.ShowDate,
|
season.ShowDate,
|
||||||
season.ShowStartsAt,
|
season.ShowStartsAt,
|
||||||
SeasonMappings.NormalizeSeasonStreamUrl(season.ShowStreamUrl),
|
|
||||||
season.CurrentPhase,
|
season.CurrentPhase,
|
||||||
season.IsCommunityOnly,
|
season.IsCommunityOnly,
|
||||||
"Twitch",
|
"Twitch",
|
||||||
@@ -92,24 +159,52 @@ public static partial class PublicEndpoints
|
|||||||
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
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)),
|
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||||
},
|
},
|
||||||
publicCategories
|
featuredCategories,
|
||||||
.Select(category => new FeaturedCategoryDto(
|
|
||||||
category.Id,
|
|
||||||
category.GroupName,
|
|
||||||
category.Name,
|
|
||||||
category.Description,
|
|
||||||
category.MaxNomineesPerUser))
|
|
||||||
.ToArray(),
|
|
||||||
winnerPreviewItems,
|
winnerPreviewItems,
|
||||||
archiveYears,
|
archiveYears,
|
||||||
new PublicSiteContentDto(
|
new PublicSiteContentDto(
|
||||||
siteSettings.HostDisplayName,
|
siteSettings.HostDisplayName,
|
||||||
siteSettings.HostTagline,
|
siteSettings.HostTagline,
|
||||||
|
siteSettings.HostArtistName,
|
||||||
|
AdminSiteSettingsEndpoints.BuildHostImageUrl(siteSettings),
|
||||||
siteSettings.NewsletterUrl,
|
siteSettings.NewsletterUrl,
|
||||||
|
siteSettings.ShareXUrl,
|
||||||
|
siteSettings.ShareDiscordUrl,
|
||||||
siteSettings.PrivacyEmail,
|
siteSettings.PrivacyEmail,
|
||||||
siteSettings.PrivacyPolicyContent,
|
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.ReadSocialLinks(siteSettings),
|
||||||
SeasonMappings.BuildFooterLinks(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));
|
SeasonMappings.ReadFaqItems(siteSettings));
|
||||||
|
|
||||||
return Results.Ok(response);
|
return Results.Ok(response);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
using Backend.Common;
|
using Backend.Common;
|
||||||
|
using Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
@@ -21,7 +22,9 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
|
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
|
||||||
var publicCategories = season.Categories
|
var publicCategories = season.Categories
|
||||||
|
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
|
||||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||||
@@ -54,17 +57,36 @@ public static partial class PublicEndpoints
|
|||||||
category.GroupName,
|
category.GroupName,
|
||||||
category.Description,
|
category.Description,
|
||||||
category.MaxNomineesPerUser,
|
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 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(
|
return new CandidateSummaryDto(
|
||||||
candidate.Id,
|
candidate.Id,
|
||||||
candidate.DisplayName,
|
candidate.DisplayName,
|
||||||
candidate.ChannelSlug,
|
candidate.ChannelSlug,
|
||||||
|
SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug),
|
||||||
candidate.Platform,
|
candidate.Platform,
|
||||||
clip?.ClipUrl,
|
clipUrl,
|
||||||
clip?.Title,
|
clipTitle,
|
||||||
clip?.Platform);
|
clipPlatform,
|
||||||
|
clipEmbedStatus);
|
||||||
}).ToArray()))
|
}).ToArray()))
|
||||||
.ToArray()));
|
.ToArray()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public static partial class PublicEndpoints
|
|||||||
|
|
||||||
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||||
if (!usesDatabaseDemo)
|
if (!usesDatabaseDemo)
|
||||||
{
|
{
|
||||||
return IsDemoLoginEnabled(configuration);
|
return IsDemoLoginEnabled(configuration);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public static partial class PublicEndpoints
|
|||||||
.Select(item => new
|
.Select(item => new
|
||||||
{
|
{
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
|
item.CategoryGroupName,
|
||||||
item.Status,
|
item.Status,
|
||||||
Nominee = item.CandidateId != null
|
Nominee = item.CandidateId != null
|
||||||
? item.Candidate!.DisplayName
|
? item.Candidate!.DisplayName
|
||||||
@@ -46,9 +47,10 @@ public static partial class PublicEndpoints
|
|||||||
var groupedNominations = nominations
|
var groupedNominations = nominations
|
||||||
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||||
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||||
.GroupBy(item => item.CategoryId)
|
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
|
||||||
.Select(group => new UserNominationStateDto(
|
.Select(group => new UserNominationStateDto(
|
||||||
group.Key,
|
group.Key.CategoryId,
|
||||||
|
group.Key.CategoryGroupName,
|
||||||
group.Select(item => item.Nominee!)
|
group.Select(item => item.Nominee!)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray()))
|
.ToArray()))
|
||||||
|
|||||||
@@ -12,37 +12,39 @@ public static partial class PublicEndpoints
|
|||||||
{
|
{
|
||||||
private static async Task<IResult> CreateVote(
|
private static async Task<IResult> CreateVote(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
CreateVoteRequest request,
|
CreateVoteRequest? request,
|
||||||
AwardsDbContext db,
|
AwardsDbContext db,
|
||||||
IUserSessionService userSessionService,
|
IUserSessionService userSessionService,
|
||||||
IRiskFlagService riskFlagService,
|
IRiskFlagService riskFlagService,
|
||||||
IRiskRuleService riskRuleService)
|
IRiskRuleService riskRuleService)
|
||||||
{
|
{
|
||||||
if (request.Entries.Length == 0)
|
var validationError = ValidateCreateVoteRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "At least one vote entry is required." });
|
return validationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
var distinctCategoryCount = request.Entries
|
var validatedRequest = request!;
|
||||||
|
var distinctCategoryCount = validatedRequest.Entries
|
||||||
.Select(item => item.CategoryId)
|
.Select(item => item.CategoryId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Count();
|
.Count();
|
||||||
|
|
||||||
if (distinctCategoryCount != request.Entries.Length)
|
if (distinctCategoryCount != validatedRequest.Entries.Length)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
|
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var season = await db.Seasons
|
var season = await db.Seasons
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(item => item.Id == request.SeasonId);
|
.FirstOrDefaultAsync(item => item.Id == validatedRequest.SeasonId);
|
||||||
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
|
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
|
||||||
if (voteSeasonResolution.Result is not null)
|
if (voteSeasonResolution.Result is not null)
|
||||||
{
|
{
|
||||||
return voteSeasonResolution.Result;
|
return voteSeasonResolution.Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
|
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
|
||||||
if (submitterIdResult.Result is not null)
|
if (submitterIdResult.Result is not null)
|
||||||
{
|
{
|
||||||
return submitterIdResult.Result;
|
return submitterIdResult.Result;
|
||||||
@@ -50,10 +52,10 @@ public static partial class PublicEndpoints
|
|||||||
|
|
||||||
var submitterId = submitterIdResult.SubmitterId!;
|
var submitterId = submitterIdResult.SubmitterId!;
|
||||||
var requestMetadata = RequestMetadataReader.Read(context);
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
|
var candidateIds = validatedRequest.Entries.Select(item => item.CandidateId).Distinct().ToArray();
|
||||||
var validCandidates = await db.Candidates
|
var validCandidates = await db.Candidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id))
|
.Where(item => item.SeasonId == validatedRequest.SeasonId && candidateIds.Contains(item.Id))
|
||||||
.Select(item => new { item.Id, item.CategoryId })
|
.Select(item => new { item.Id, item.CategoryId })
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
@@ -63,17 +65,17 @@ public static partial class PublicEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
|
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
|
||||||
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
|
if (validatedRequest.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
|
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var ballot = await db.VoteBallots
|
var ballot = await db.VoteBallots
|
||||||
.Include(item => item.Entries)
|
.Include(item => item.Entries)
|
||||||
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
|
.FirstOrDefaultAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId);
|
||||||
|
|
||||||
var isResubmission = ballot is not null;
|
var isResubmission = ballot is not null;
|
||||||
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
|
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
|
||||||
|
|
||||||
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
|
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
|
||||||
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
|
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
|
||||||
@@ -90,9 +92,9 @@ public static partial class PublicEndpoints
|
|||||||
db.ChangeTracker.Clear();
|
db.ChangeTracker.Clear();
|
||||||
ballot = await db.VoteBallots
|
ballot = await db.VoteBallots
|
||||||
.Include(item => item.Entries)
|
.Include(item => item.Entries)
|
||||||
.FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
|
.FirstAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
|
||||||
isResubmission = true;
|
isResubmission = true;
|
||||||
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
|
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,21 +109,21 @@ public static partial class PublicEndpoints
|
|||||||
if (isResubmission && resubmittedBallotRule.Enabled)
|
if (isResubmission && resubmittedBallotRule.Enabled)
|
||||||
{
|
{
|
||||||
await riskFlagService.AddIfMissingAsync(
|
await riskFlagService.AddIfMissingAsync(
|
||||||
request.SeasonId,
|
validatedRequest.SeasonId,
|
||||||
submitterId,
|
submitterId,
|
||||||
"vote",
|
"vote",
|
||||||
"resubmitted_ballot",
|
"resubmitted_ballot",
|
||||||
resubmittedBallotRule.Severity,
|
resubmittedBallotRule.Severity,
|
||||||
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
|
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
new { ballotId = ballot.Id, entryCount = request.Entries.Length, entityLinks = new[] { ballotLink } },
|
new { ballotId = ballot.Id, entryCount = validatedRequest.Entries.Length, entityLinks = new[] { ballotLink } },
|
||||||
context.RequestAborted);
|
context.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
|
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
|
||||||
{
|
{
|
||||||
await riskFlagService.AddIfMissingAsync(
|
await riskFlagService.AddIfMissingAsync(
|
||||||
request.SeasonId,
|
validatedRequest.SeasonId,
|
||||||
submitterId,
|
submitterId,
|
||||||
"vote",
|
"vote",
|
||||||
"rapid_vote_updates",
|
"rapid_vote_updates",
|
||||||
|
|||||||
@@ -9,17 +9,58 @@ public static partial class PublicEndpoints
|
|||||||
{
|
{
|
||||||
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
|
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
|
||||||
{
|
{
|
||||||
|
var latestPublishedWinnerYear = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(result => result.Season.WinnersPublishedAt != null)
|
||||||
|
.Select(result => (int?)result.Season.Year)
|
||||||
|
.MaxAsync();
|
||||||
|
|
||||||
|
var archivedWinnerRows = await db.ArchivedWinners
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Year == year)
|
||||||
|
.OrderBy(item => item.Category)
|
||||||
|
.ThenBy(item => item.Subcategory)
|
||||||
|
.ThenBy(item => item.WinnerName)
|
||||||
|
.ToArrayAsync();
|
||||||
|
if (archivedWinnerRows.Length > 0)
|
||||||
|
{
|
||||||
|
var archivedItems = archivedWinnerRows
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var metadata = SeasonMappings.InferProfileMetadataFromUrl(item.WinnerUrl, item.WinnerName);
|
||||||
|
return new WinnerArchiveItemDto(
|
||||||
|
item.Subcategory,
|
||||||
|
item.Category,
|
||||||
|
item.WinnerName,
|
||||||
|
metadata.Slug,
|
||||||
|
metadata.Platform,
|
||||||
|
item.WinnerUrl,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Results.Ok(new WinnerArchiveResponse(year, archivedItems));
|
||||||
|
}
|
||||||
|
|
||||||
var season = await db.Seasons
|
var season = await db.Seasons
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.Year == year)
|
.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();
|
.FirstOrDefaultAsync();
|
||||||
if (season is null)
|
if (season is null)
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
|
if (season.WinnersPublishedAt is null)
|
||||||
|
{
|
||||||
|
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestPublishedWinnerYear == season.Year)
|
||||||
{
|
{
|
||||||
return Results.Ok(new WinnerArchiveResponse(year, []));
|
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||||
}
|
}
|
||||||
@@ -31,28 +72,32 @@ public static partial class PublicEndpoints
|
|||||||
.OrderBy(result => result.CategoryName)
|
.OrderBy(result => result.CategoryName)
|
||||||
.Select(result => new
|
.Select(result => new
|
||||||
{
|
{
|
||||||
|
CategoryGroup = result.Category.GroupName,
|
||||||
result.CategoryName,
|
result.CategoryName,
|
||||||
WinnerName = result.Candidate.DisplayName,
|
WinnerName = result.Candidate.DisplayName,
|
||||||
WinnerSlug = result.Candidate.ChannelSlug,
|
WinnerSlug = result.Candidate.ChannelSlug,
|
||||||
WinnerPlatform = result.Candidate.Platform,
|
WinnerPlatform = result.Candidate.Platform,
|
||||||
|
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||||
|
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||||
|
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||||
|
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||||
})
|
})
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var items = winnerRows
|
var items = winnerRows
|
||||||
.Select(result => new WinnerArchiveItemDto(
|
.Select(result => new WinnerArchiveItemDto(
|
||||||
|
result.CategoryGroup,
|
||||||
result.CategoryName,
|
result.CategoryName,
|
||||||
result.WinnerName,
|
result.WinnerName,
|
||||||
result.WinnerSlug,
|
result.WinnerSlug,
|
||||||
result.WinnerPlatform,
|
result.WinnerPlatform,
|
||||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||||
|
result.ClipUrl,
|
||||||
|
result.ClipTitle,
|
||||||
|
result.ClipPlatform,
|
||||||
|
result.ClipEmbedStatus))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return Results.Ok(new WinnerArchiveResponse(year, items));
|
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.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
|
services.AddHttpClient("TwitchTracker", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(4);
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
|
||||||
|
});
|
||||||
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
||||||
|
|
||||||
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
||||||
@@ -88,6 +93,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
||||||
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
||||||
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
||||||
|
services.AddScoped<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
|
||||||
|
services.AddScoped<NominationTrackingReviewService>();
|
||||||
|
services.AddScoped<NominationEnrichmentService>();
|
||||||
services.AddScoped<AdminSessionFilter>();
|
services.AddScoped<AdminSessionFilter>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -47,17 +47,11 @@ public static class WebApplicationExtensions
|
|||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await SessionBootstrapper.EnsureAsync(db);
|
|
||||||
await OperationalTablesBootstrapper.EnsureAsync(db);
|
|
||||||
await TeamAccountBootstrapper.EnsureAsync(db, app.Configuration);
|
await TeamAccountBootstrapper.EnsureAsync(db, app.Configuration);
|
||||||
if (ShouldSeedPresentationData(app))
|
|
||||||
{
|
|
||||||
await SeedDataBootstrapper.EnsureAsync(db);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,17 +63,4 @@ public static class WebApplicationExtensions
|
|||||||
app.MapPublicEndpoints();
|
app.MapPublicEndpoints();
|
||||||
app.MapAdminEndpoints();
|
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1527
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCandidatePreparationFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AcceptanceNote",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AcceptanceStatus",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "character varying(30)",
|
||||||
|
maxLength: 30,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "open");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClipCompilationPlatform",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "character varying(40)",
|
||||||
|
maxLength: 40,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClipCompilationTitle",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClipCompilationUrl",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClipEmbedStatus",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "character varying(30)",
|
||||||
|
maxLength: 30,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "unchecked");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 4,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 5,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 6,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 7,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 8,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 9,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 10,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 11,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 12,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 13,
|
||||||
|
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
|
||||||
|
values: new object[] { null, "open", null, null, null, "unchecked" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AcceptanceNote",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AcceptanceStatus",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClipCompilationPlatform",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClipCompilationTitle",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClipCompilationUrl",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClipEmbedStatus",
|
||||||
|
table: "Candidates");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWorkflowRulesJson : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "WorkflowRulesJson",
|
||||||
|
value: "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "WorkflowRulesJson";
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1552
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOptionalClipFeatureSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.';
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: new[] { "ClipReviewEnabled", "ClipSubmissionDisabledMessage" },
|
||||||
|
values: new object[] { true, "Clip-Einreichungen sind aktuell geschlossen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ClipReviewEnabled";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ClipSubmissionDisabledMessage";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ClipSubmissionsEnabled";
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNominationLinkBlacklist : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NominationLinkBlacklistJson",
|
||||||
|
table: "SiteSettings",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "[]");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "NominationLinkBlacklistJson",
|
||||||
|
value: "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NominationLinkBlacklistJson",
|
||||||
|
table: "SiteSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClipAdminMenuVisible : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ClipAdminMenuVisible" boolean NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ShowactsUrl" character varying(400) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT TRUE;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS "ShowactApplications" (
|
||||||
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"SeasonId" integer NOT NULL,
|
||||||
|
"ArtistName" character varying(120) NOT NULL,
|
||||||
|
"ContactEmail" character varying(180) NOT NULL,
|
||||||
|
"ContactDiscord" character varying(120) NOT NULL,
|
||||||
|
"PlatformUrl" character varying(500) NOT NULL,
|
||||||
|
"PerformanceType" character varying(80) NOT NULL,
|
||||||
|
"Description" character varying(1000) NOT NULL,
|
||||||
|
"TechnicalNotes" character varying(1000) NOT NULL,
|
||||||
|
"ReferenceUrl" character varying(500) NOT NULL,
|
||||||
|
"Status" character varying(20) NOT NULL,
|
||||||
|
"ReviewNote" character varying(500) NULL,
|
||||||
|
"ReviewedByTwitchId" character varying(120) NULL,
|
||||||
|
"CreatedFromIp" character varying(80) NOT NULL,
|
||||||
|
"UserAgent" character varying(400) NOT NULL,
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL,
|
||||||
|
"ReviewedAt" timestamp with time zone NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "Sponsors" (
|
||||||
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"SeasonId" integer NOT NULL,
|
||||||
|
"Name" character varying(120) NOT NULL,
|
||||||
|
"WebsiteUrl" character varying(500) NOT NULL,
|
||||||
|
"LogoUrl" character varying(500) NOT NULL,
|
||||||
|
"Description" character varying(500) NOT NULL,
|
||||||
|
"Tier" character varying(80) NOT NULL,
|
||||||
|
"SortOrder" integer NOT NULL,
|
||||||
|
"IsVisible" boolean NOT NULL,
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL,
|
||||||
|
"UpdatedAt" timestamp with time zone NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'FK_ShowactApplications_Seasons_SeasonId'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "ShowactApplications"
|
||||||
|
ADD CONSTRAINT "FK_ShowactApplications_Seasons_SeasonId"
|
||||||
|
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'FK_Sponsors_Seasons_SeasonId'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "Sponsors"
|
||||||
|
ADD CONSTRAINT "FK_Sponsors_Seasons_SeasonId"
|
||||||
|
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: new[] { "ClipAdminMenuVisible", "ShowactApplicationDisabledMessage", "ShowactsContent", "ShowactsUrl", "SponsorsVisible", "WorkflowRulesJson" },
|
||||||
|
values: new object[] { true, "Showact-Bewerbungen sind aktuell geschlossen.", "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", "https://vtuber-star-awards.de/showacts", true, "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" });
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_ShowactApplications_SeasonId_Status"
|
||||||
|
ON "ShowactApplications" ("SeasonId", "Status");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_Sponsors_SeasonId_IsVisible_SortOrder"
|
||||||
|
ON "Sponsors" ("SeasonId", "IsVisible", "SortOrder");
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
DROP TABLE IF EXISTS "ShowactApplications";
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS "Sponsors";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ClipAdminMenuVisible";
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ShowactApplicationDisabledMessage";
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ShowactApplicationsEnabled";
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ShowactsContent";
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "ShowactsUrl";
|
||||||
|
|
||||||
|
ALTER TABLE "SiteSettings"
|
||||||
|
DROP COLUMN IF EXISTS "SponsorsVisible";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "WorkflowRulesJson",
|
||||||
|
value: "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1768
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddShareUrls : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShareDiscordUrl",
|
||||||
|
table: "SiteSettings",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShareXUrl",
|
||||||
|
table: "SiteSettings",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: new[] { "ShareDiscordUrl", "ShareXUrl" },
|
||||||
|
values: new object[] { "", "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShareDiscordUrl",
|
||||||
|
table: "SiteSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShareXUrl",
|
||||||
|
table: "SiteSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddShowactDynamicForm : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShowactFormSchemaJson",
|
||||||
|
table: "SiteSettings",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FieldResponsesJson",
|
||||||
|
table: "ShowactApplications",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "ShowactFormSchemaJson",
|
||||||
|
value: "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShowactFormSchemaJson",
|
||||||
|
table: "SiteSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FieldResponsesJson",
|
||||||
|
table: "ShowactApplications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCategoryViewerRanges : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ViewerRangeMax",
|
||||||
|
table: "Categories",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ViewerRangeMin",
|
||||||
|
table: "Categories",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 4,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 5,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 6,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 7,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 8,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 9,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 10,
|
||||||
|
columns: new[] { "ViewerRangeMax", "ViewerRangeMin" },
|
||||||
|
values: new object[] { null, null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ViewerRangeMax",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ViewerRangeMin",
|
||||||
|
table: "Categories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1789
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSessionIdleTimeoutSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SessionIdleTimeoutHours",
|
||||||
|
table: "SiteSettings",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 3);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "SiteSettings",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "SessionIdleTimeoutHours",
|
||||||
|
value: 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SessionIdleTimeoutHours",
|
||||||
|
table: "SiteSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1799
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSeasonSubcategoryTemplates : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SubcategoryTemplatesJson",
|
||||||
|
table: "Seasons",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "[]");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Seasons",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "SubcategoryTemplatesJson",
|
||||||
|
value: "[]");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Seasons",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "SubcategoryTemplatesJson",
|
||||||
|
value: "[]");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Seasons",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "SubcategoryTemplatesJson",
|
||||||
|
value: "[]");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Seasons",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 4,
|
||||||
|
column: "SubcategoryTemplatesJson",
|
||||||
|
value: "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubcategoryTemplatesJson",
|
||||||
|
table: "Seasons");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1936
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNominationGroupTrackerIdentity : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Nominations_Categories_CategoryId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "CategoryId",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AvgViewers",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CategoryGroupName",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "character varying(80)",
|
||||||
|
maxLength: 80,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ResolvedChannel",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ResolvedPlatform",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "character varying(40)",
|
||||||
|
maxLength: 40,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "StreamerIdentityId",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SuggestedCategoryId",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "TrackerCheckedAt",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TrackerStatus",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "character varying(40)",
|
||||||
|
maxLength: 40,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "pending");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "NominationTally",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "StreamerIdentityId",
|
||||||
|
table: "Candidates",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
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.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 4,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 5,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 6,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 7,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 8,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 9,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 10,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 11,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 12,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Candidates",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 13,
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Nominations",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" },
|
||||||
|
values: new object[] { null, "", null, null, null, null, null, "pending" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Nominations",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" },
|
||||||
|
values: new object[] { null, "", null, null, null, null, null, "pending" });
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE "Nominations" n
|
||||||
|
SET "CategoryGroupName" = c."GroupName"
|
||||||
|
FROM "Categories" c
|
||||||
|
WHERE n."CategoryId" = c."Id"
|
||||||
|
AND COALESCE(n."CategoryGroupName", '') = '';
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Nominations_SeasonId_CategoryGroupName_Status",
|
||||||
|
table: "Nominations",
|
||||||
|
columns: new[] { "SeasonId", "CategoryGroupName", "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_Candidates_StreamerIdentityId",
|
||||||
|
table: "Candidates",
|
||||||
|
column: "StreamerIdentityId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_StreamerIdentities_NormalizedKey",
|
||||||
|
table: "StreamerIdentities",
|
||||||
|
column: "NormalizedKey",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Candidates_StreamerIdentities_StreamerIdentityId",
|
||||||
|
table: "Candidates",
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
principalTable: "StreamerIdentities",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Nominations_Categories_CategoryId",
|
||||||
|
table: "Nominations",
|
||||||
|
column: "CategoryId",
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Nominations_Categories_SuggestedCategoryId",
|
||||||
|
table: "Nominations",
|
||||||
|
column: "SuggestedCategoryId",
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Nominations_StreamerIdentities_StreamerIdentityId",
|
||||||
|
table: "Nominations",
|
||||||
|
column: "StreamerIdentityId",
|
||||||
|
principalTable: "StreamerIdentities",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Candidates_StreamerIdentities_StreamerIdentityId",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Nominations_Categories_CategoryId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Nominations_Categories_SuggestedCategoryId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Nominations_StreamerIdentities_StreamerIdentityId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "StreamerIdentities");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Nominations_SeasonId_CategoryGroupName_Status",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Nominations_StreamerIdentityId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Nominations_SuggestedCategoryId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Candidates_StreamerIdentityId",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AvgViewers",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CategoryGroupName",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ResolvedChannel",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ResolvedPlatform",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "StreamerIdentityId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SuggestedCategoryId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TrackerCheckedAt",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TrackerStatus",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NominationTally",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "StreamerIdentityId",
|
||||||
|
table: "Candidates");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "CategoryId",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Nominations_Categories_CategoryId",
|
||||||
|
table: "Nominations",
|
||||||
|
column: "CategoryId",
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1942
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