Compare commits
44 Commits
d015fcbe6f
..
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 | |||
| 494eba5edd | |||
| f225addfe8 | |||
| 9393d433fa | |||
| aeade132f9 | |||
| 47fe59c42a | |||
| 90795759f3 | |||
| ca69cdb0de | |||
| 3499ad0df5 | |||
| 6a6eb412ae | |||
| 85b0a3fa42 | |||
| 8b69dfbafb | |||
| b7804e10ea | |||
| 3bf8b00a83 | |||
| e2f31d99fd | |||
| 693769e5a8 | |||
| 54293f4c45 | |||
| fd81c968f6 | |||
| f851e500f7 | |||
| fef1d36fe8 | |||
| 17134b3b82 | |||
| 30fb931bd3 |
@@ -0,0 +1,330 @@
|
|||||||
|
name: CI - Build & Verify
|
||||||
|
run-name: CI ${{ gitea.ref_name }} by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vtubeawards-ci-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
no_cache:
|
||||||
|
description: 'Build Docker images without cache'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Build, Typecheck & Hygiene
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Reject tracked build output
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tracked="$(git ls-files frontend/dist Backend/bin Backend/obj || true)"
|
||||||
|
if [ -n "$tracked" ]; then
|
||||||
|
echo "Build output is tracked and must be removed:"
|
||||||
|
echo "$tracked"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Reject tracked handoff artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tracked="$(git ls-files '*.zip' '*.docx' 'prototype/**' 'update_v2_docx_*.py' || true)"
|
||||||
|
if [ -n "$tracked" ]; then
|
||||||
|
echo "Handoff/prototype artifacts are tracked and must be removed:"
|
||||||
|
echo "$tracked"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Secret pattern scan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
hits="$(
|
||||||
|
grep -RInE '(SECRET|TOKEN|PASSWORD|API_KEY)[[:space:]]*[:=][[:space:]]*.{8,}' \
|
||||||
|
--include='*.cs' \
|
||||||
|
--include='*.ts' \
|
||||||
|
--include='*.vue' \
|
||||||
|
Backend frontend/src 2>/dev/null || true
|
||||||
|
)"
|
||||||
|
if [ -n "$hits" ]; then
|
||||||
|
echo "Possible hardcoded secret found:"
|
||||||
|
echo "$hits"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup .NET SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Backend build
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
dotnet restore Backend/Backend.csproj
|
||||||
|
dotnet build Backend/Backend.csproj --no-restore --configuration Release
|
||||||
|
|
||||||
|
- name: Frontend build
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
base_version="$(node -p "require('./package.json').version")"
|
||||||
|
run_number="${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}}"
|
||||||
|
if [ "$run_number" != "0" ]; then
|
||||||
|
export VITE_BUILD_VERSION="${base_version}+build.${run_number}"
|
||||||
|
else
|
||||||
|
export VITE_BUILD_VERSION="${base_version}+local"
|
||||||
|
fi
|
||||||
|
export VITE_BUILD_DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
echo "Building frontend version ${VITE_BUILD_VERSION}"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to award.noveria.net
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: verify
|
||||||
|
timeout-minutes: 30
|
||||||
|
if: |
|
||||||
|
gitea.event_name == 'workflow_dispatch' ||
|
||||||
|
(gitea.event_name == 'push' && gitea.ref == 'refs/heads/main')
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /home/projekte_bao/vtuber-awards
|
||||||
|
DEPLOY_APP_PATH: /home/projekte_bao/vtuber-awards/app
|
||||||
|
DB_NETWORK: vtuber-awards_internal
|
||||||
|
LIVE_URL: https://award.noveria.net
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve deploy metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' frontend/package.json | head -n 1)"
|
||||||
|
test -n "$version"
|
||||||
|
run_number="${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}}"
|
||||||
|
if [ "$run_number" != "0" ]; then
|
||||||
|
build_version="${version}+build.${run_number}"
|
||||||
|
else
|
||||||
|
build_version="${version}+local"
|
||||||
|
fi
|
||||||
|
echo "sha=$(git rev-parse HEAD)" >> "$GITEA_OUTPUT"
|
||||||
|
echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITEA_OUTPUT"
|
||||||
|
echo "version=$version" >> "$GITEA_OUTPUT"
|
||||||
|
echo "build_version=$build_version" >> "$GITEA_OUTPUT"
|
||||||
|
echo "Deploying version ${build_version}"
|
||||||
|
git log -1 --oneline
|
||||||
|
|
||||||
|
- name: Verify production host layout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm \
|
||||||
|
-v "${DEPLOY_PATH}:/deploy:ro" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -lc '
|
||||||
|
test -f /deploy/compose.yaml
|
||||||
|
test -f /deploy/deploy/backend.Dockerfile
|
||||||
|
test -f /deploy/deploy/web.Dockerfile
|
||||||
|
test -d /deploy/app
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Sync code to production app directory
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tar --exclude=.git -cf - . | docker run --rm -i \
|
||||||
|
-v "${DEPLOY_APP_PATH}:/dest" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -lc '
|
||||||
|
set -e
|
||||||
|
dest_owner="$(stat -c "%u:%g" /dest)"
|
||||||
|
find /dest -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
|
||||||
|
tar -xf - -C /dest
|
||||||
|
chown -R "$dest_owner" /dest
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Write frontend build metadata
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
build_date="$(date -u +%Y-%m-%d)"
|
||||||
|
docker run --rm \
|
||||||
|
-e BUILD_VERSION="${{ steps.meta.outputs.build_version }}" \
|
||||||
|
-e BUILD_DATE="${build_date}" \
|
||||||
|
-v "${DEPLOY_APP_PATH}:/app" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -lc '
|
||||||
|
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 "{\n \"version\": \"%s\",\n \"buildDate\": \"%s\"\n}\n" "$BUILD_VERSION" "$BUILD_DATE" > /app/frontend/public/build-meta.json
|
||||||
|
echo "Frontend build metadata:"
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
no_cache="${{ gitea.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
|
||||||
|
build_args=""
|
||||||
|
if [ "$no_cache" = "true" ]; then
|
||||||
|
build_args="--no-cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 "docker compose config >/dev/null && docker compose build ${build_args} api web"
|
||||||
|
|
||||||
|
- name: Backup database before migrations
|
||||||
|
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
|
||||||
|
mkdir -p backups
|
||||||
|
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
backup="backups/predeploy-${{ steps.meta.outputs.short_sha }}-${stamp}.dump"
|
||||||
|
docker compose exec -T postgres sh -lc '"'"'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc'"'"' > "$backup"
|
||||||
|
test -s "$backup"
|
||||||
|
find backups -name "predeploy-*.dump" -mtime +14 -delete
|
||||||
|
echo "Database backup written to $backup"
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Apply EF Core migrations
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm \
|
||||||
|
-e DEPLOY_APP_PATH="${DEPLOY_APP_PATH}" \
|
||||||
|
-e DB_NETWORK="${DB_NETWORK}" \
|
||||||
|
-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
|
||||||
|
db="$(docker compose exec -T postgres printenv POSTGRES_DB)"
|
||||||
|
user="$(docker compose exec -T postgres printenv POSTGRES_USER)"
|
||||||
|
password="$(docker compose exec -T postgres printenv POSTGRES_PASSWORD)"
|
||||||
|
docker run --rm \
|
||||||
|
--network "${DB_NETWORK}" \
|
||||||
|
-v "${DEPLOY_APP_PATH}/Backend:/src/Backend" \
|
||||||
|
-w /src/Backend \
|
||||||
|
-e "VTSA_POSTGRES=Host=postgres;Port=5432;Database=${db};Username=${user};Password=${password}" \
|
||||||
|
mcr.microsoft.com/dotnet/sdk:8.0-alpine \
|
||||||
|
sh -lc "dotnet tool install --global dotnet-ef >/dev/null && export PATH=\$PATH:/root/.dotnet/tools && dotnet restore >/dev/null && dotnet ef database update"
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Restart production services
|
||||||
|
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 'docker compose up -d --wait --force-recreate api web'
|
||||||
|
|
||||||
|
- name: Verify live health
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
retry() {
|
||||||
|
label="$1"
|
||||||
|
url="$2"
|
||||||
|
attempts=10
|
||||||
|
wait=3
|
||||||
|
for attempt in $(seq 1 "$attempts"); do
|
||||||
|
if body="$(curl -fsS --max-time 15 "$url")"; then
|
||||||
|
echo "$label OK on attempt $attempt"
|
||||||
|
echo "$body"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "$label failed on attempt $attempt/$attempts; waiting ${wait}s"
|
||||||
|
sleep "$wait"
|
||||||
|
done
|
||||||
|
echo "$label failed after $attempts attempts"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
retry "API health" "${LIVE_URL}/api/health"
|
||||||
|
db_body="$(curl -fsS --max-time 15 "${LIVE_URL}/api/health/database")"
|
||||||
|
echo "$db_body"
|
||||||
|
echo "$db_body" | grep -q '"canConnect":true'
|
||||||
|
echo "$db_body" | grep -q '"pendingMigrations":\[\]'
|
||||||
|
|
||||||
|
- name: Verify frontend assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
index="$(curl -fsS --max-time 15 "${LIVE_URL}/")"
|
||||||
|
js_asset="$(printf "%s" "$index" | grep -Eo '/assets/index-[^"]+\.js' | head -n 1)"
|
||||||
|
css_asset="$(printf "%s" "$index" | grep -Eo '/assets/index-[^"]+\.css' | head -n 1)"
|
||||||
|
test -n "$js_asset"
|
||||||
|
test -n "$css_asset"
|
||||||
|
curl -fsS --max-time 20 "${LIVE_URL}${js_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 version verified: ${{ steps.meta.outputs.build_version }}"
|
||||||
|
|
||||||
|
- name: Show production containers
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
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 'docker compose ps'
|
||||||
+44
@@ -4,5 +4,49 @@ 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
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
*.docx
|
||||||
|
|
||||||
# Local Claude Code config (settings, preview launch configs)
|
# Local Claude Code config (settings, preview launch configs)
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Local design/prototype exports and audit screenshots
|
||||||
|
.design-source/
|
||||||
|
.od-skills/
|
||||||
|
audit/
|
||||||
|
prototype/
|
||||||
|
update_v2_docx_*.py
|
||||||
|
*.dc.html
|
||||||
|
*.dc.html.artifact.json
|
||||||
|
/frontend/public/*.html
|
||||||
|
/*-drawing-*.png
|
||||||
|
|||||||
@@ -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.
|
||||||
+10
-1
@@ -1 +1,10 @@
|
|||||||
VTSA_POSTGRES=Host=localhost;Port=5432;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres
|
VTSA_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__1=http://127.0.0.1:5173
|
||||||
|
VTSA_DEMO_LOGIN_ENABLED=true
|
||||||
|
VTSA_DEMO_ADMIN_LOGIN=jayuhime_admin
|
||||||
|
VTSA_DEMO_ADMIN_EMAIL=admin@example.local
|
||||||
|
VTSA_DEMO_ADMIN_PASSWORD=change-me-for-demo
|
||||||
|
VTSA_DEMO_ADMIN_TWITCH_ID=jayuhime_admin
|
||||||
|
VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Backend.Common;
|
||||||
|
|
||||||
|
public static class ApplicationDefaults
|
||||||
|
{
|
||||||
|
public static readonly string[] FrontendOrigins =
|
||||||
|
[
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
|
"http://127.0.0.1:5174",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://127.0.0.1:4173",
|
||||||
|
];
|
||||||
|
|
||||||
|
public const string FrontendCorsPolicy = "frontend";
|
||||||
|
public const string AuthRateLimitPolicy = "auth";
|
||||||
|
public const string PublicWriteRateLimitPolicy = "public-write";
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Backend.Common;
|
||||||
|
|
||||||
|
public sealed record RequestMetadata(string ClientIp, string UserAgent);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Backend.Common;
|
||||||
|
|
||||||
|
public static class RequestMetadataReader
|
||||||
|
{
|
||||||
|
public static RequestMetadata Read(HttpContext context) =>
|
||||||
|
new(ReadClientIp(context), ReadUserAgent(context));
|
||||||
|
|
||||||
|
public static string ReadClientIp(HttpContext context) =>
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
|
||||||
|
public static string ReadUserAgent(HttpContext context)
|
||||||
|
{
|
||||||
|
var value = context.Request.Headers.UserAgent.ToString().Trim();
|
||||||
|
return value.Length > 400 ? value[..400] : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Domain;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace Backend.Common;
|
||||||
|
|
||||||
|
public static class SeasonMappings
|
||||||
|
{
|
||||||
|
private static readonly Regex HtmlBreakRegex = new(@"<\s*br\s*/?>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HtmlListItemOpenRegex = new(@"<\s*li\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HtmlBlockCloseRegex = new(@"</\s*(p|div|li|ul|ol|h1|h2|h3|h4|h5|h6)\s*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HtmlTagRegex = new(@"<[^>]*>", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex MultiNewlineRegex = new(@"\n{3,}", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static bool IsSeasonScheduleValid(
|
||||||
|
DateOnly nominationStartsAt,
|
||||||
|
DateOnly nominationEndsAt,
|
||||||
|
DateOnly votingStartsAt,
|
||||||
|
DateOnly votingEndsAt,
|
||||||
|
DateOnly reviewStartsAt,
|
||||||
|
DateOnly reviewEndsAt,
|
||||||
|
DateOnly showDate)
|
||||||
|
{
|
||||||
|
return nominationStartsAt <= nominationEndsAt
|
||||||
|
&& nominationEndsAt <= votingStartsAt
|
||||||
|
&& votingStartsAt <= votingEndsAt
|
||||||
|
&& votingEndsAt <= reviewStartsAt
|
||||||
|
&& reviewStartsAt <= reviewEndsAt
|
||||||
|
&& reviewEndsAt <= showDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildProfileUrl(string platform, string channelSlug)
|
||||||
|
{
|
||||||
|
var normalizedPlatform = platform.Trim().ToLowerInvariant();
|
||||||
|
var platformKey = new string(normalizedPlatform.Where(char.IsLetterOrDigit).ToArray());
|
||||||
|
var slug = channelSlug.Trim();
|
||||||
|
var cleanSlug = slug.TrimStart('@');
|
||||||
|
if (slug.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
slug.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(cleanSlug))
|
||||||
|
{
|
||||||
|
return "#";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(platformKey))
|
||||||
|
{
|
||||||
|
return cleanSlug.Contains('.') ? $"https://{cleanSlug}" : "#";
|
||||||
|
}
|
||||||
|
|
||||||
|
return platformKey switch
|
||||||
|
{
|
||||||
|
"artstation" => $"https://www.artstation.com/{cleanSlug}",
|
||||||
|
"bilibili" => $"https://space.bilibili.com/{cleanSlug}",
|
||||||
|
"bluesky" => $"https://bsky.app/profile/{cleanSlug}",
|
||||||
|
"booth" => $"https://{cleanSlug}.booth.pm",
|
||||||
|
"cake" => $"https://cake.gg/@{cleanSlug}",
|
||||||
|
"discord" => cleanSlug.Contains("discord.", StringComparison.OrdinalIgnoreCase) ? $"https://{cleanSlug}" : $"https://discord.gg/{cleanSlug}",
|
||||||
|
"deviantart" => $"https://www.deviantart.com/{cleanSlug}",
|
||||||
|
"facebook" => $"https://facebook.com/{cleanSlug}",
|
||||||
|
"fanbox" => $"https://{cleanSlug}.fanbox.cc",
|
||||||
|
"github" => $"https://github.com/{cleanSlug}",
|
||||||
|
"instagram" => $"https://instagram.com/{cleanSlug}",
|
||||||
|
"kick" => $"https://kick.com/{cleanSlug}",
|
||||||
|
"kofi" or "ko-fi" => $"https://ko-fi.com/{cleanSlug}",
|
||||||
|
"linktree" => $"https://linktr.ee/{cleanSlug}",
|
||||||
|
"mastodon" => cleanSlug.Contains('@') ? $"https://{cleanSlug.Split('@').Last()}/@{cleanSlug.Split('@').First()}" : $"https://mastodon.social/@{cleanSlug}",
|
||||||
|
"patreon" => $"https://patreon.com/{cleanSlug}",
|
||||||
|
"picarto" or "picartotv" => $"https://picarto.tv/{cleanSlug}",
|
||||||
|
"pinterest" => $"https://pinterest.com/{cleanSlug}",
|
||||||
|
"pixiv" => $"https://www.pixiv.net/users/{cleanSlug}",
|
||||||
|
"reddit" => $"https://reddit.com/user/{cleanSlug}",
|
||||||
|
"skeb" => $"https://skeb.jp/@{cleanSlug}",
|
||||||
|
"soundcloud" => $"https://soundcloud.com/{cleanSlug}",
|
||||||
|
"spotify" => $"https://open.spotify.com/user/{cleanSlug}",
|
||||||
|
"telegram" => $"https://t.me/{cleanSlug}",
|
||||||
|
"threads" => $"https://threads.net/@{cleanSlug}",
|
||||||
|
"tiktok" => $"https://tiktok.com/@{cleanSlug}",
|
||||||
|
"trovo" => $"https://trovo.live/s/{cleanSlug}",
|
||||||
|
"twitch" => $"https://twitch.tv/{cleanSlug}",
|
||||||
|
"tumblr" => $"https://{cleanSlug}.tumblr.com",
|
||||||
|
"vimeo" => $"https://vimeo.com/{cleanSlug}",
|
||||||
|
"website" or "link" => cleanSlug.Contains('.') ? $"https://{cleanSlug}" : $"https://{cleanSlug}.com",
|
||||||
|
"youtube" => cleanSlug.StartsWith("@", StringComparison.Ordinal) ? $"https://youtube.com/{cleanSlug}" : $"https://youtube.com/@{cleanSlug}",
|
||||||
|
"x" or "twitter" => $"https://x.com/{cleanSlug}",
|
||||||
|
_ => $"https://{platformKey}.com/{cleanSlug}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var trimmed = value?.Trim() ?? string.Empty;
|
||||||
|
return string.IsNullOrWhiteSpace(trimmed) ? "https://twitch.tv/jayuhime" : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizePlainTextContent(string? value)
|
||||||
|
{
|
||||||
|
var trimmed = value?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var withBreakHints = HtmlBreakRegex.Replace(trimmed, "\n");
|
||||||
|
withBreakHints = HtmlListItemOpenRegex.Replace(withBreakHints, "- ");
|
||||||
|
withBreakHints = HtmlBlockCloseRegex.Replace(withBreakHints, "\n");
|
||||||
|
withBreakHints = HtmlTagRegex.Replace(withBreakHints, " ");
|
||||||
|
withBreakHints = WebUtility.HtmlDecode(withBreakHints).Replace("\r\n", "\n").Replace('\r', '\n');
|
||||||
|
|
||||||
|
var normalizedLines = withBreakHints
|
||||||
|
.Split('\n')
|
||||||
|
.Select(line => line.Trim())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return MultiNewlineRegex.Replace(string.Join('\n', normalizedLines), "\n\n").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizePhaseKey(string? currentPhase)
|
||||||
|
{
|
||||||
|
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
|
if (value.Contains("abgeschlossen") || value.Contains("archiv") || value.Contains("complete") || value.Contains("ended"))
|
||||||
|
{
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains("show"))
|
||||||
|
{
|
||||||
|
return "show";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains("aufbereit") || value.Contains("vorbereit") || value.Contains("pause") || value.Contains("review") || value.Contains("auswert"))
|
||||||
|
{
|
||||||
|
return "preparation";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains("vot"))
|
||||||
|
{
|
||||||
|
return "voting";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains("nomin"))
|
||||||
|
{
|
||||||
|
return "nomination";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "nomination";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolveTimelineState(string itemKey, string currentPhaseKey)
|
||||||
|
{
|
||||||
|
string[] phaseOrder = ["nomination", "voting", "preparation", "show"];
|
||||||
|
if (string.Equals(currentPhaseKey, "completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return phaseOrder.Contains(itemKey) ? "done" : "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemIndex = Array.IndexOf(phaseOrder, itemKey);
|
||||||
|
var currentIndex = Array.IndexOf(phaseOrder, currentPhaseKey);
|
||||||
|
if (itemIndex < 0)
|
||||||
|
{
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex < 0)
|
||||||
|
{
|
||||||
|
currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemIndex < currentIndex)
|
||||||
|
{
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemIndex == currentIndex)
|
||||||
|
{
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T[] DeserializeSiteArray<T>(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T[]>(
|
||||||
|
json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublicSocialLinkDto[] ReadSocialLinks(SiteSettings settings) =>
|
||||||
|
DeserializeSiteArray<PublicSocialLinkDto>(settings.SocialLinksJson);
|
||||||
|
|
||||||
|
public static FaqItemDto[] ReadFaqItems(SiteSettings settings) =>
|
||||||
|
DeserializeSiteArray<FaqItemDto>(settings.FaqJson);
|
||||||
|
|
||||||
|
public static FooterLinkDto[] BuildFooterLinks(SiteSettings settings) =>
|
||||||
|
[
|
||||||
|
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
|
||||||
|
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
|
||||||
|
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,8 @@
|
|||||||
|
namespace Backend.Configuration;
|
||||||
|
|
||||||
|
public sealed class FrontendOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Frontend";
|
||||||
|
|
||||||
|
public string[] AllowedOrigins { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Backend.Configuration;
|
||||||
|
|
||||||
|
public sealed class TwitchAuthOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "TwitchAuth";
|
||||||
|
|
||||||
|
public string ClientId { get; init; } = string.Empty;
|
||||||
|
public string ClientSecret { get; init; } = string.Empty;
|
||||||
|
public string RedirectUri { get; init; } = string.Empty;
|
||||||
|
public string Scope { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
namespace Backend.Contracts;
|
|
||||||
|
|
||||||
public sealed record AdminMetricDto(string Label, int Value, string Note);
|
|
||||||
|
|
||||||
public sealed record AdminActivityDto(string Label, string Age);
|
|
||||||
|
|
||||||
public sealed record AdminTopCategoryDto(string Category, int Votes);
|
|
||||||
|
|
||||||
public sealed record AdminRiskFlagDto(
|
|
||||||
int Id,
|
|
||||||
string Source,
|
|
||||||
string Type,
|
|
||||||
string Severity,
|
|
||||||
string Status,
|
|
||||||
string Summary,
|
|
||||||
string? TwitchUserId,
|
|
||||||
string CreatedFromIp,
|
|
||||||
DateTimeOffset CreatedAt,
|
|
||||||
string MetadataJson);
|
|
||||||
|
|
||||||
public sealed record AdminAuditEntryDto(
|
|
||||||
int Id,
|
|
||||||
string AdminTwitchUserId,
|
|
||||||
string ActionType,
|
|
||||||
string EntityType,
|
|
||||||
string EntityId,
|
|
||||||
string Summary,
|
|
||||||
DateTimeOffset CreatedAt);
|
|
||||||
|
|
||||||
public sealed record AdminDashboardResponse(
|
|
||||||
IEnumerable<AdminMetricDto> Metrics,
|
|
||||||
IEnumerable<AdminActivityDto> Activities,
|
|
||||||
IEnumerable<AdminTopCategoryDto> TopCategories,
|
|
||||||
IEnumerable<AdminRiskFlagDto> RiskFlags,
|
|
||||||
IEnumerable<AdminAuditEntryDto> AuditEntries);
|
|
||||||
|
|
||||||
public sealed record AdminSeasonListItemDto(
|
|
||||||
int Id,
|
|
||||||
int Year,
|
|
||||||
string Name,
|
|
||||||
string CurrentPhase,
|
|
||||||
bool IsCurrent,
|
|
||||||
int CategoryCount);
|
|
||||||
|
|
||||||
public sealed record AdminCategoryItemDto(
|
|
||||||
int Id,
|
|
||||||
string GroupName,
|
|
||||||
string Name,
|
|
||||||
string Slug,
|
|
||||||
string Description,
|
|
||||||
int SortOrder,
|
|
||||||
int MaxNomineesPerUser,
|
|
||||||
int CandidateCount);
|
|
||||||
|
|
||||||
public sealed record AdminCandidateItemDto(
|
|
||||||
int Id,
|
|
||||||
int CategoryId,
|
|
||||||
string DisplayName,
|
|
||||||
string ChannelSlug,
|
|
||||||
string Platform);
|
|
||||||
|
|
||||||
public sealed record AdminNominationReviewItemDto(
|
|
||||||
int Id,
|
|
||||||
int CategoryId,
|
|
||||||
string CategoryName,
|
|
||||||
string SubmittedByTwitchId,
|
|
||||||
string CandidateText,
|
|
||||||
DateTimeOffset CreatedAt);
|
|
||||||
|
|
||||||
public sealed record AdminClipSubmissionItemDto(
|
|
||||||
int Id,
|
|
||||||
int? CategoryId,
|
|
||||||
string SubmittedByTwitchId,
|
|
||||||
string ClipUrl,
|
|
||||||
string Title,
|
|
||||||
string Creator,
|
|
||||||
string Platform,
|
|
||||||
string Status,
|
|
||||||
DateTimeOffset CreatedAt);
|
|
||||||
|
|
||||||
public sealed record AdminSeasonDetailResponse(
|
|
||||||
int Id,
|
|
||||||
int Year,
|
|
||||||
string Name,
|
|
||||||
string CurrentPhase,
|
|
||||||
bool IsCurrent,
|
|
||||||
IEnumerable<AdminCategoryItemDto> Categories,
|
|
||||||
IEnumerable<AdminCandidateItemDto> Candidates,
|
|
||||||
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
|
||||||
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
|
||||||
|
|
||||||
public sealed record UpdateSeasonRequest(
|
|
||||||
string CurrentPhase,
|
|
||||||
bool IsCurrent);
|
|
||||||
|
|
||||||
public sealed record UpsertCategoryRequest(
|
|
||||||
string GroupName,
|
|
||||||
string Name,
|
|
||||||
string Slug,
|
|
||||||
string Description,
|
|
||||||
int SortOrder,
|
|
||||||
int MaxNomineesPerUser);
|
|
||||||
|
|
||||||
public sealed record UpsertCandidateRequest(
|
|
||||||
int CategoryId,
|
|
||||||
string DisplayName,
|
|
||||||
string ChannelSlug,
|
|
||||||
string Platform);
|
|
||||||
|
|
||||||
public sealed record ApproveNominationRequest(
|
|
||||||
string? DisplayName,
|
|
||||||
string? ChannelSlug,
|
|
||||||
string? Platform);
|
|
||||||
|
|
||||||
public sealed record ResolveRiskFlagRequest(string Status);
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record AdminMetricDto(string Label, int Value, string Note);
|
||||||
|
|
||||||
|
public sealed record AdminActivityDto(string Label, string Age);
|
||||||
|
|
||||||
|
public sealed record AdminTopCategoryDto(string Category, int Value, string Basis);
|
||||||
|
|
||||||
|
public sealed record AdminDashboardResponse(
|
||||||
|
int SeasonId,
|
||||||
|
int Year,
|
||||||
|
string SeasonName,
|
||||||
|
bool IsCurrent,
|
||||||
|
IEnumerable<AdminMetricDto> Metrics,
|
||||||
|
IEnumerable<AdminActivityDto> Activities,
|
||||||
|
IEnumerable<AdminTopCategoryDto> TopCategories,
|
||||||
|
IEnumerable<AdminRiskFlagDto> RiskFlags,
|
||||||
|
IEnumerable<AdminAuditEntryDto> AuditEntries);
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record AdminRiskFlagDto(
|
||||||
|
int Id,
|
||||||
|
string Source,
|
||||||
|
string Type,
|
||||||
|
string Severity,
|
||||||
|
string Status,
|
||||||
|
string Summary,
|
||||||
|
string? TwitchUserId,
|
||||||
|
string CreatedFromIp,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string MetadataJson,
|
||||||
|
string? ReviewNote,
|
||||||
|
string? ReviewedByTwitchId,
|
||||||
|
DateTimeOffset? ReviewedAt,
|
||||||
|
AdminRiskEntityLinkDto[] EntityLinks);
|
||||||
|
|
||||||
|
public sealed record AdminRiskEntityLinkDto(
|
||||||
|
string Label,
|
||||||
|
string EntityType,
|
||||||
|
string EntityId,
|
||||||
|
string To);
|
||||||
|
|
||||||
|
public sealed record AdminRiskCountDto(string Key, int Count);
|
||||||
|
|
||||||
|
public sealed record AdminRiskFlagsResponse(
|
||||||
|
AdminRiskFlagDto[] Items,
|
||||||
|
int TotalCount,
|
||||||
|
int ReturnedCount,
|
||||||
|
int Offset,
|
||||||
|
int Limit,
|
||||||
|
bool HasMore,
|
||||||
|
AdminRiskCountDto[] SeverityCounts,
|
||||||
|
AdminRiskCountDto[] StatusCounts);
|
||||||
|
|
||||||
|
public sealed record AdminAuditEntryDto(
|
||||||
|
int Id,
|
||||||
|
string AdminTwitchUserId,
|
||||||
|
string ActionType,
|
||||||
|
string EntityType,
|
||||||
|
string EntityId,
|
||||||
|
string Summary,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string MetadataJson,
|
||||||
|
string? CreatedFromIp,
|
||||||
|
string UserAgent);
|
||||||
|
|
||||||
|
public sealed record AdminAuditEntriesResponse(
|
||||||
|
AdminAuditEntryDto[] Items,
|
||||||
|
int TotalCount,
|
||||||
|
int ReturnedCount,
|
||||||
|
string? NextCursor,
|
||||||
|
int Limit);
|
||||||
|
|
||||||
|
public sealed record AdminNominationReviewItemDto(
|
||||||
|
int Id,
|
||||||
|
int? CategoryId,
|
||||||
|
string CategoryGroupName,
|
||||||
|
string CategoryName,
|
||||||
|
string SubmittedByTwitchId,
|
||||||
|
string CandidateText,
|
||||||
|
string? StreamUrl,
|
||||||
|
string? ResolvedChannel,
|
||||||
|
string? ResolvedPlatform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int? SuggestedCategoryId,
|
||||||
|
string? SuggestedCategoryName,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string TrackerStatus,
|
||||||
|
DateTimeOffset? TrackerCheckedAt,
|
||||||
|
string TrackingReviewStatus,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||||
|
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||||
|
string? TrackingReviewNote,
|
||||||
|
string? TrackingReviewedByTwitchId,
|
||||||
|
DateTimeOffset? TrackingReviewedAt,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
int? CandidateId,
|
||||||
|
string? CandidateDisplayName,
|
||||||
|
string? ReviewNote,
|
||||||
|
string? ReviewedByTwitchId,
|
||||||
|
DateTimeOffset? ReviewedAt);
|
||||||
|
|
||||||
|
public sealed record AdminNominationReviewGroupDto(
|
||||||
|
int Id,
|
||||||
|
int[] NominationIds,
|
||||||
|
string CategoryGroupName,
|
||||||
|
string DisplayName,
|
||||||
|
string? StreamUrl,
|
||||||
|
string? ResolvedChannel,
|
||||||
|
string? ResolvedPlatform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int? SuggestedCategoryId,
|
||||||
|
string? SuggestedCategoryName,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string TrackerStatus,
|
||||||
|
DateTimeOffset? TrackerCheckedAt,
|
||||||
|
string TrackingReviewStatus,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
AdminTrackingFlagHitDto[] TrackingFlags,
|
||||||
|
AdminTrackingMetricStateDto[] TrackingMetrics,
|
||||||
|
string? TrackingReviewNote,
|
||||||
|
string? TrackingReviewedByTwitchId,
|
||||||
|
DateTimeOffset? TrackingReviewedAt,
|
||||||
|
int NominationTally,
|
||||||
|
int UniqueSubmitterCount,
|
||||||
|
DateTimeOffset FirstSubmittedAt,
|
||||||
|
DateTimeOffset LastSubmittedAt);
|
||||||
|
|
||||||
|
public sealed record AdminClipSubmissionItemDto(
|
||||||
|
int Id,
|
||||||
|
int? CategoryId,
|
||||||
|
int? CandidateId,
|
||||||
|
string SubmittedByTwitchId,
|
||||||
|
string ClipUrl,
|
||||||
|
string Title,
|
||||||
|
string Creator,
|
||||||
|
string Platform,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string? ReviewNote,
|
||||||
|
string? ReviewedByTwitchId,
|
||||||
|
DateTimeOffset? ReviewedAt);
|
||||||
|
|
||||||
|
public sealed record ApproveNominationRequest(
|
||||||
|
string? DisplayName,
|
||||||
|
string? ChannelSlug,
|
||||||
|
string? Platform,
|
||||||
|
int? CategoryId,
|
||||||
|
string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record RejectNominationRequest(string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record ReopenRejectedNominationRequest(string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record UpdateNominationTrackingReviewRequest(
|
||||||
|
string Status,
|
||||||
|
string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
|
||||||
|
|
||||||
|
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
|
||||||
|
|
||||||
|
public sealed record UpdateNominationLinkBlacklistRequest(string[] Urls);
|
||||||
|
|
||||||
|
public sealed record AddNominationLinkBlacklistEntryRequest(string Url);
|
||||||
|
|
||||||
|
public sealed record UpdateClipStatusRequest(
|
||||||
|
string Status,
|
||||||
|
string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record ResolveRiskFlagRequest(
|
||||||
|
string Status,
|
||||||
|
string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record BulkResolveRiskFlagsRequest(
|
||||||
|
int[] RiskFlagIds,
|
||||||
|
string Status,
|
||||||
|
string? ReviewNote);
|
||||||
|
|
||||||
|
public sealed record AdminRiskRuleDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Enabled,
|
||||||
|
int Threshold,
|
||||||
|
int WindowMinutes,
|
||||||
|
string Severity,
|
||||||
|
string Description);
|
||||||
|
|
||||||
|
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
|
||||||
|
|
||||||
|
public sealed record UpdateRiskRulesRequest(AdminRiskRuleDto[] Rules);
|
||||||
|
|
||||||
|
public sealed record AdminWorkflowRuleDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Enabled,
|
||||||
|
int Limit,
|
||||||
|
string Mode,
|
||||||
|
string Description);
|
||||||
|
|
||||||
|
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
|
||||||
|
|
||||||
|
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingFlagHitDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string Severity,
|
||||||
|
string Description,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
bool BlocksApproval,
|
||||||
|
bool AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingMetricStateDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Required,
|
||||||
|
string SourceSupport,
|
||||||
|
bool Present,
|
||||||
|
string Value,
|
||||||
|
string Description,
|
||||||
|
string WindowKey,
|
||||||
|
string WindowLabel,
|
||||||
|
bool AutoWindowSupported);
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record AdminSeasonListItemDto(
|
||||||
|
int Id,
|
||||||
|
int Year,
|
||||||
|
string Name,
|
||||||
|
string CurrentPhase,
|
||||||
|
bool IsCurrent,
|
||||||
|
bool IsDemo,
|
||||||
|
int CategoryCount,
|
||||||
|
DateTimeOffset? WinnersPublishedAt,
|
||||||
|
string? WinnersPublishedByTwitchId);
|
||||||
|
|
||||||
|
public sealed record AdminCategoryItemDto(
|
||||||
|
int Id,
|
||||||
|
string GroupName,
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
string Description,
|
||||||
|
int SortOrder,
|
||||||
|
int MaxNomineesPerUser,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax,
|
||||||
|
int CandidateCount);
|
||||||
|
|
||||||
|
public sealed record AdminSubcategoryTemplateDto(
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
int SortOrder,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax);
|
||||||
|
|
||||||
|
public sealed record AdminCandidateItemDto(
|
||||||
|
int Id,
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string Platform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int Votes,
|
||||||
|
int NominationTally,
|
||||||
|
string AcceptanceStatus,
|
||||||
|
string? AcceptanceNote,
|
||||||
|
string? ClipCompilationUrl,
|
||||||
|
string? ClipCompilationTitle,
|
||||||
|
string? ClipCompilationPlatform,
|
||||||
|
string ClipEmbedStatus);
|
||||||
|
|
||||||
|
public sealed record AdminAwardResultItemDto(
|
||||||
|
int Id,
|
||||||
|
int CategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
int CandidateId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string CandidateDisplayName,
|
||||||
|
string CandidateChannelSlug,
|
||||||
|
string CandidatePlatform);
|
||||||
|
|
||||||
|
public sealed record AdminVotingWorkspaceSummaryDto(
|
||||||
|
int TotalVotes,
|
||||||
|
int TotalBallots,
|
||||||
|
int TotalSubcategories,
|
||||||
|
int VotedSubcategories,
|
||||||
|
int ReadySubcategories,
|
||||||
|
int ProblemSubcategories,
|
||||||
|
int WinnerSetSubcategories);
|
||||||
|
|
||||||
|
public sealed record AdminVotingCandidateRankDto(
|
||||||
|
int CandidateId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string Platform,
|
||||||
|
int Votes,
|
||||||
|
int VoteSharePercent,
|
||||||
|
int NominationTally,
|
||||||
|
bool HasClip,
|
||||||
|
string ClipEmbedStatus,
|
||||||
|
bool HasWinnerConflict,
|
||||||
|
bool IsCurrentWinner,
|
||||||
|
bool IsAccepted,
|
||||||
|
bool IsTopTie);
|
||||||
|
|
||||||
|
public sealed record AdminVotingCategoryWorkspaceItemDto(
|
||||||
|
int CategoryId,
|
||||||
|
string GroupName,
|
||||||
|
string CategoryName,
|
||||||
|
int SortOrder,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax,
|
||||||
|
int VoteCount,
|
||||||
|
int BallotCount,
|
||||||
|
int CandidateCount,
|
||||||
|
int ReadyCandidateCount,
|
||||||
|
int NominationCount,
|
||||||
|
int OpenReviewCount,
|
||||||
|
bool HasWinner,
|
||||||
|
bool WinnerReady,
|
||||||
|
bool HasTopVoteTie,
|
||||||
|
bool HasMissingClip,
|
||||||
|
bool HasRuleConflict,
|
||||||
|
bool HasOpenReviews,
|
||||||
|
bool HasSoftNominatorWarning,
|
||||||
|
IEnumerable<AdminVotingCandidateRankDto> Leaderboard);
|
||||||
|
|
||||||
|
public sealed record AdminVotingWorkspaceDto(
|
||||||
|
AdminVotingWorkspaceSummaryDto Summary,
|
||||||
|
IEnumerable<AdminVotingCategoryWorkspaceItemDto> Categories);
|
||||||
|
|
||||||
|
public sealed record AdminSeasonDetailResponse(
|
||||||
|
int Id,
|
||||||
|
int Year,
|
||||||
|
string Name,
|
||||||
|
bool IsDemo,
|
||||||
|
string CurrentPhase,
|
||||||
|
bool IsCurrent,
|
||||||
|
bool IsCommunityOnly,
|
||||||
|
DateOnly NominationStartsAt,
|
||||||
|
DateOnly NominationEndsAt,
|
||||||
|
DateOnly VotingStartsAt,
|
||||||
|
DateOnly VotingEndsAt,
|
||||||
|
DateOnly ReviewStartsAt,
|
||||||
|
DateOnly ReviewEndsAt,
|
||||||
|
DateOnly ShowDate,
|
||||||
|
TimeOnly ShowStartsAt,
|
||||||
|
DateTimeOffset? WinnersPublishedAt,
|
||||||
|
string? WinnersPublishedByTwitchId,
|
||||||
|
IEnumerable<AdminSubcategoryTemplateDto> SubcategoryTemplates,
|
||||||
|
IEnumerable<AdminCategoryItemDto> Categories,
|
||||||
|
IEnumerable<AdminCandidateItemDto> Candidates,
|
||||||
|
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
|
||||||
|
IEnumerable<AdminNominationReviewGroupDto> PendingNominationGroups,
|
||||||
|
IEnumerable<AdminNominationReviewItemDto> ReviewedNominations,
|
||||||
|
string TrackingReviewNotes,
|
||||||
|
bool ShowTrackingReviewNotes,
|
||||||
|
IEnumerable<AdminAwardResultItemDto> Results,
|
||||||
|
AdminVotingWorkspaceDto VotingWorkspace,
|
||||||
|
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
|
||||||
|
|
||||||
|
public sealed record CreateSeasonRequest(
|
||||||
|
int Year,
|
||||||
|
string Name,
|
||||||
|
string CurrentPhase,
|
||||||
|
bool IsCurrent,
|
||||||
|
bool IsCommunityOnly,
|
||||||
|
DateOnly NominationStartsAt,
|
||||||
|
DateOnly NominationEndsAt,
|
||||||
|
DateOnly VotingStartsAt,
|
||||||
|
DateOnly VotingEndsAt,
|
||||||
|
DateOnly ReviewStartsAt,
|
||||||
|
DateOnly ReviewEndsAt,
|
||||||
|
DateOnly ShowDate,
|
||||||
|
TimeOnly ShowStartsAt,
|
||||||
|
int? CopyStructureFromSeasonId = null);
|
||||||
|
|
||||||
|
public sealed record UpdateSeasonRequest(
|
||||||
|
int Year,
|
||||||
|
string Name,
|
||||||
|
string CurrentPhase,
|
||||||
|
bool IsCurrent,
|
||||||
|
bool IsCommunityOnly,
|
||||||
|
DateOnly NominationStartsAt,
|
||||||
|
DateOnly NominationEndsAt,
|
||||||
|
DateOnly VotingStartsAt,
|
||||||
|
DateOnly VotingEndsAt,
|
||||||
|
DateOnly ReviewStartsAt,
|
||||||
|
DateOnly ReviewEndsAt,
|
||||||
|
DateOnly ShowDate,
|
||||||
|
TimeOnly ShowStartsAt);
|
||||||
|
|
||||||
|
public sealed record UpsertCategoryRequest(
|
||||||
|
string GroupName,
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
string Description,
|
||||||
|
int SortOrder,
|
||||||
|
int MaxNomineesPerUser,
|
||||||
|
int? ViewerRangeMin,
|
||||||
|
int? ViewerRangeMax);
|
||||||
|
|
||||||
|
public sealed record UpdateSeasonSubcategoryTemplatesRequest(
|
||||||
|
AdminSubcategoryTemplateDto[] Templates);
|
||||||
|
|
||||||
|
public sealed record UpsertCategoryGroupRequest(
|
||||||
|
string GroupName,
|
||||||
|
string Description,
|
||||||
|
int SortOrder,
|
||||||
|
int MaxNomineesPerUser);
|
||||||
|
|
||||||
|
public sealed record UpsertCandidateRequest(
|
||||||
|
int CategoryId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string Platform,
|
||||||
|
string? AcceptanceStatus = null,
|
||||||
|
string? AcceptanceNote = null,
|
||||||
|
string? ClipCompilationUrl = null,
|
||||||
|
string? ClipCompilationTitle = null,
|
||||||
|
string? ClipCompilationPlatform = null,
|
||||||
|
string? ClipEmbedStatus = null);
|
||||||
|
|
||||||
|
public sealed record SetAwardResultRequest(
|
||||||
|
int CategoryId,
|
||||||
|
int CandidateId);
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record AdminSiteSettingsResponse(
|
||||||
|
string HostDisplayName,
|
||||||
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
|
string HostImageUrl,
|
||||||
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
|
string PrivacyEmail,
|
||||||
|
string PrivacyPolicyContent,
|
||||||
|
string? PrivacyPolicyUpdatedBy,
|
||||||
|
DateTimeOffset? PrivacyPolicyUpdatedAt,
|
||||||
|
string ImprintUrl,
|
||||||
|
string ImprintContent,
|
||||||
|
string ContactUrl,
|
||||||
|
string ContactContent,
|
||||||
|
string SponsorsUrl,
|
||||||
|
string SponsorsContent,
|
||||||
|
string ShowactsUrl,
|
||||||
|
string ShowactsContent,
|
||||||
|
string StreamBannerEyebrow,
|
||||||
|
string StreamBannerTitle,
|
||||||
|
string StreamBannerText,
|
||||||
|
string StreamBannerLiveButtonLabel,
|
||||||
|
string StreamBannerLiveButtonUrl,
|
||||||
|
string StreamBannerLockedButtonLabel,
|
||||||
|
bool StreamBannerUseCompletedContent,
|
||||||
|
string StreamBannerCompletedEyebrow,
|
||||||
|
string StreamBannerCompletedTitle,
|
||||||
|
string StreamBannerCompletedText,
|
||||||
|
string StreamBannerCompletedButtonLabel,
|
||||||
|
string StreamBannerCompletedButtonUrl,
|
||||||
|
string AwardsSectionTitle,
|
||||||
|
string AwardsSectionDescription,
|
||||||
|
string SubcategoriesSectionTitle,
|
||||||
|
string SubcategoriesSectionDescription,
|
||||||
|
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||||
|
IEnumerable<FaqItemDto> Faq,
|
||||||
|
string ShowactFormSchemaJson);
|
||||||
|
|
||||||
|
public sealed record UpdateSiteSettingsRequest(
|
||||||
|
string HostDisplayName,
|
||||||
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
|
string PrivacyEmail,
|
||||||
|
string PrivacyPolicyContent,
|
||||||
|
string ImprintUrl,
|
||||||
|
string ImprintContent,
|
||||||
|
string ContactUrl,
|
||||||
|
string ContactContent,
|
||||||
|
string SponsorsUrl,
|
||||||
|
string SponsorsContent,
|
||||||
|
string ShowactsUrl,
|
||||||
|
string ShowactsContent,
|
||||||
|
string StreamBannerEyebrow,
|
||||||
|
string StreamBannerTitle,
|
||||||
|
string StreamBannerText,
|
||||||
|
string StreamBannerLiveButtonLabel,
|
||||||
|
string StreamBannerLiveButtonUrl,
|
||||||
|
string StreamBannerLockedButtonLabel,
|
||||||
|
bool StreamBannerUseCompletedContent,
|
||||||
|
string StreamBannerCompletedEyebrow,
|
||||||
|
string StreamBannerCompletedTitle,
|
||||||
|
string StreamBannerCompletedText,
|
||||||
|
string StreamBannerCompletedButtonLabel,
|
||||||
|
string StreamBannerCompletedButtonUrl,
|
||||||
|
string AwardsSectionTitle,
|
||||||
|
string AwardsSectionDescription,
|
||||||
|
string SubcategoriesSectionTitle,
|
||||||
|
string SubcategoriesSectionDescription,
|
||||||
|
PublicSocialLinkDto[] SocialLinks,
|
||||||
|
FaqItemDto[] Faq,
|
||||||
|
string? ShowactFormSchemaJson = null);
|
||||||
|
|
||||||
|
public sealed record AdminOperationalSettingsResponse(
|
||||||
|
bool DemoLoginManagedByDatabase,
|
||||||
|
bool DemoLoginEnabled,
|
||||||
|
string DemoLoginEmail,
|
||||||
|
bool DemoLoginPasswordSet,
|
||||||
|
string DemoLoginTwitchUserId,
|
||||||
|
string DemoLoginDisplayName,
|
||||||
|
bool TwitchAuthManagedByDatabase,
|
||||||
|
bool TwitchAuthConfigured,
|
||||||
|
string TwitchClientId,
|
||||||
|
bool TwitchClientSecretSet,
|
||||||
|
string TwitchRedirectUri,
|
||||||
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
|
bool MaintenanceModeEnabled,
|
||||||
|
string MaintenanceTitle,
|
||||||
|
string MaintenanceMessage);
|
||||||
|
|
||||||
|
public sealed record AdminOptionalFeatureSettingsResponse(
|
||||||
|
bool ClipSubmissionsEnabled,
|
||||||
|
bool ClipReviewEnabled,
|
||||||
|
bool ClipAdminMenuVisible,
|
||||||
|
string ClipSubmissionDisabledMessage,
|
||||||
|
bool ShowactApplicationsEnabled,
|
||||||
|
DateOnly? ShowactApplicationStartsAt,
|
||||||
|
DateOnly? ShowactApplicationEndsAt,
|
||||||
|
bool ShowactApplicationsOpenNow,
|
||||||
|
string ShowactApplicationDisabledMessage,
|
||||||
|
bool SponsorsVisible);
|
||||||
|
|
||||||
|
public sealed record UpdateOptionalFeatureSettingsRequest(
|
||||||
|
bool ClipSubmissionsEnabled,
|
||||||
|
bool ClipReviewEnabled,
|
||||||
|
bool ClipAdminMenuVisible,
|
||||||
|
string ClipSubmissionDisabledMessage,
|
||||||
|
bool ShowactApplicationsEnabled,
|
||||||
|
DateOnly? ShowactApplicationStartsAt,
|
||||||
|
DateOnly? ShowactApplicationEndsAt,
|
||||||
|
string ShowactApplicationDisabledMessage,
|
||||||
|
bool SponsorsVisible);
|
||||||
|
|
||||||
|
public sealed record UpdateOperationalSettingsRequest(
|
||||||
|
bool DemoLoginEnabled,
|
||||||
|
string DemoLoginEmail,
|
||||||
|
string? DemoLoginPassword,
|
||||||
|
string DemoLoginTwitchUserId,
|
||||||
|
string DemoLoginDisplayName,
|
||||||
|
string TwitchClientId,
|
||||||
|
string? TwitchClientSecret,
|
||||||
|
string TwitchRedirectUri,
|
||||||
|
string TwitchScope,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
|
bool MaintenanceModeEnabled,
|
||||||
|
string MaintenanceTitle,
|
||||||
|
string MaintenanceMessage);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingSourceDto(
|
||||||
|
string ProviderKey,
|
||||||
|
string ProviderLabel,
|
||||||
|
string BaseUrl,
|
||||||
|
string NotesSummary,
|
||||||
|
bool ShowManualReviewNotesInReview);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingMetricRuleDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Enabled,
|
||||||
|
string SourceSupport,
|
||||||
|
string Description,
|
||||||
|
bool RequiredForAutoClassification,
|
||||||
|
bool ShowInReview,
|
||||||
|
bool ShowInAdminSummary,
|
||||||
|
bool ManualOverrideAllowed,
|
||||||
|
string WindowKey,
|
||||||
|
string[] AutoSupportedWindowKeys,
|
||||||
|
string? ProviderFieldKey,
|
||||||
|
int? TopCount,
|
||||||
|
int? MinPrimaryCategorySharePercent,
|
||||||
|
int? MinPrimaryCategoryHours,
|
||||||
|
int? MaxDistinctCategoriesBeforeFlag,
|
||||||
|
string[] IgnoredCategories,
|
||||||
|
bool MatchAwardCategoryAgainstTopCategories,
|
||||||
|
bool FlagIfAwardCategoryNotInTopX,
|
||||||
|
bool FlagIfCategorySpreadTooWide,
|
||||||
|
bool FlagIfNoCategoryContextAvailable,
|
||||||
|
int? MinValue,
|
||||||
|
int? MaxValue);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingFlagRuleDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
bool Enabled,
|
||||||
|
string Severity,
|
||||||
|
string Description,
|
||||||
|
bool AutoTriggerEnabled,
|
||||||
|
bool RequiresManualReview,
|
||||||
|
bool BlocksApproval,
|
||||||
|
bool AdminNoteRequiredOnOverride);
|
||||||
|
|
||||||
|
public sealed record AdminTrackingRulesResponse(
|
||||||
|
AdminTrackingSourceDto Source,
|
||||||
|
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||||
|
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||||
|
AdminTrackingFlagRuleDto[] Flags,
|
||||||
|
string ManualReviewNotes);
|
||||||
|
|
||||||
|
public sealed record UpdateTrackingRulesRequest(
|
||||||
|
AdminTrackingSourceDto Source,
|
||||||
|
AdminTrackingMetricRuleDto[] ImportantMetrics,
|
||||||
|
AdminTrackingMetricRuleDto[] OptionalMetrics,
|
||||||
|
AdminTrackingFlagRuleDto[] Flags,
|
||||||
|
string ManualReviewNotes);
|
||||||
|
|
||||||
|
public sealed record UpdateTrackingSourceRequest(
|
||||||
|
AdminTrackingSourceDto Source);
|
||||||
|
|
||||||
|
public sealed record UpdateTrackingReviewNotesRequest(
|
||||||
|
string ManualReviewNotes,
|
||||||
|
bool ShowManualReviewNotesInReview);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record AdminTeamResponse(
|
||||||
|
IEnumerable<AdminTeamMemberDto> Members,
|
||||||
|
IEnumerable<AdminTeamRoleDto> Roles,
|
||||||
|
IEnumerable<AdminTeamPermissionDto> Permissions);
|
||||||
|
|
||||||
|
public sealed record AdminTeamMemberDto(
|
||||||
|
int Id,
|
||||||
|
string Login,
|
||||||
|
string DisplayName,
|
||||||
|
string Role,
|
||||||
|
string? BoundTwitchUserId,
|
||||||
|
string? BoundTwitchDisplayName,
|
||||||
|
bool IsActive,
|
||||||
|
bool MustChangePassword,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? UpdatedAt,
|
||||||
|
DateTimeOffset? LastLoginAt,
|
||||||
|
DateTimeOffset? LastOnlineAt,
|
||||||
|
bool IsOnline,
|
||||||
|
DateTimeOffset? TwitchBoundAt,
|
||||||
|
DateTimeOffset? PasswordResetAt);
|
||||||
|
|
||||||
|
public sealed record AdminTeamRoleDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string Description,
|
||||||
|
bool IsSystemRole,
|
||||||
|
IEnumerable<string> PermissionKeys);
|
||||||
|
|
||||||
|
public sealed record AdminTeamPermissionDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string Description,
|
||||||
|
string GroupLabel,
|
||||||
|
string MenuPath,
|
||||||
|
bool ReadOnlySupported);
|
||||||
|
|
||||||
|
public sealed record CreateTeamMemberRequest(
|
||||||
|
string Login,
|
||||||
|
string DisplayName,
|
||||||
|
string Role);
|
||||||
|
|
||||||
|
public sealed record UpdateTeamMemberRequest(
|
||||||
|
string Login,
|
||||||
|
string DisplayName,
|
||||||
|
string Role,
|
||||||
|
bool IsActive);
|
||||||
|
|
||||||
|
public sealed record UpdateTeamRolesRequest(
|
||||||
|
AdminTeamRoleUpdateDto[] Roles);
|
||||||
|
|
||||||
|
public sealed record AdminTeamRoleUpdateDto(
|
||||||
|
string Key,
|
||||||
|
string[] PermissionKeys);
|
||||||
|
|
||||||
|
public sealed record TeamMemberPasswordResponse(
|
||||||
|
bool Saved,
|
||||||
|
int MemberId,
|
||||||
|
string GeneratedPassword,
|
||||||
|
bool MustChangePassword);
|
||||||
|
|
||||||
|
public sealed record DeleteTeamMemberResponse(
|
||||||
|
bool Deleted,
|
||||||
|
int MemberId);
|
||||||
@@ -5,8 +5,40 @@ public sealed record LoginRequest(
|
|||||||
string DisplayName,
|
string DisplayName,
|
||||||
string Role);
|
string Role);
|
||||||
|
|
||||||
|
public sealed record DemoLoginRequest(
|
||||||
|
string? Login,
|
||||||
|
string? Email,
|
||||||
|
string? Password);
|
||||||
|
|
||||||
|
public sealed record TeamLoginRequest(
|
||||||
|
string Login,
|
||||||
|
string Password);
|
||||||
|
|
||||||
|
public sealed record ChangePasswordRequest(
|
||||||
|
string CurrentPassword,
|
||||||
|
string NewPassword);
|
||||||
|
|
||||||
|
public sealed record TwitchAuthorizeRequest(
|
||||||
|
string Purpose,
|
||||||
|
string? ReturnUrl,
|
||||||
|
string? FrontendOrigin);
|
||||||
|
|
||||||
|
public sealed record TwitchAuthorizeResponse(
|
||||||
|
string AuthorizationUrl);
|
||||||
|
|
||||||
public sealed record AuthSessionDto(
|
public sealed record AuthSessionDto(
|
||||||
string SessionToken,
|
string SessionToken,
|
||||||
string TwitchUserId,
|
string TwitchUserId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string Role);
|
string Role,
|
||||||
|
IEnumerable<string> PermissionKeys,
|
||||||
|
int SessionIdleTimeoutHours,
|
||||||
|
bool MustChangePassword = false,
|
||||||
|
string? TeamLogin = null,
|
||||||
|
string? BoundTwitchUserId = null,
|
||||||
|
string? BoundTwitchDisplayName = null);
|
||||||
|
|
||||||
|
public sealed record TwitchBindingDisconnectResponse(
|
||||||
|
bool Disconnected,
|
||||||
|
bool LoggedOut,
|
||||||
|
AuthSessionDto? Session);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
namespace Backend.Contracts;
|
|
||||||
|
|
||||||
public sealed record TimelineItem(
|
|
||||||
string Key,
|
|
||||||
string Title,
|
|
||||||
DateOnly StartsAt,
|
|
||||||
DateOnly EndsAt,
|
|
||||||
string State);
|
|
||||||
|
|
||||||
public sealed record FeaturedCategoryDto(
|
|
||||||
int Id,
|
|
||||||
string GroupName,
|
|
||||||
string Name,
|
|
||||||
string Description,
|
|
||||||
int MaxNomineesPerUser);
|
|
||||||
|
|
||||||
public sealed record WinnerPreviewDto(
|
|
||||||
int Year,
|
|
||||||
string Category,
|
|
||||||
string WinnerName,
|
|
||||||
string WinnerSlug);
|
|
||||||
|
|
||||||
public sealed record FaqItemDto(string Question, string Answer);
|
|
||||||
|
|
||||||
public sealed record OverviewResponse(
|
|
||||||
int SeasonId,
|
|
||||||
int Year,
|
|
||||||
string Title,
|
|
||||||
DateOnly ShowDate,
|
|
||||||
string CurrentPhase,
|
|
||||||
bool IsCommunityOnly,
|
|
||||||
string LoginProvider,
|
|
||||||
IEnumerable<TimelineItem> Timeline,
|
|
||||||
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
|
|
||||||
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
|
||||||
IEnumerable<FaqItemDto> Faq);
|
|
||||||
|
|
||||||
public sealed record CandidateSummaryDto(
|
|
||||||
int Id,
|
|
||||||
string DisplayName,
|
|
||||||
string ChannelSlug,
|
|
||||||
string Platform);
|
|
||||||
|
|
||||||
public sealed record PublicCategoryDetailDto(
|
|
||||||
int Id,
|
|
||||||
string Name,
|
|
||||||
string GroupName,
|
|
||||||
string Description,
|
|
||||||
int MaxNomineesPerUser,
|
|
||||||
IEnumerable<CandidateSummaryDto> Candidates);
|
|
||||||
|
|
||||||
public sealed record SeasonCategoriesResponse(
|
|
||||||
int SeasonId,
|
|
||||||
int Year,
|
|
||||||
IEnumerable<PublicCategoryDetailDto> Categories);
|
|
||||||
|
|
||||||
public sealed record WinnerArchiveItemDto(
|
|
||||||
string Category,
|
|
||||||
string WinnerName,
|
|
||||||
string WinnerSlug);
|
|
||||||
|
|
||||||
public sealed record WinnerArchiveResponse(
|
|
||||||
int Year,
|
|
||||||
IEnumerable<WinnerArchiveItemDto> Items);
|
|
||||||
|
|
||||||
public sealed record CreateNominationRequest(
|
|
||||||
int Year,
|
|
||||||
int CategoryId,
|
|
||||||
string TwitchUserId,
|
|
||||||
string[] Nominees);
|
|
||||||
|
|
||||||
public sealed record VoteEntryRequest(
|
|
||||||
int CategoryId,
|
|
||||||
int CandidateId);
|
|
||||||
|
|
||||||
public sealed record CreateVoteRequest(
|
|
||||||
int SeasonId,
|
|
||||||
string TwitchUserId,
|
|
||||||
VoteEntryRequest[] Entries);
|
|
||||||
|
|
||||||
public sealed record CreateClipRequest(
|
|
||||||
int Year,
|
|
||||||
int? CategoryId,
|
|
||||||
string TwitchUserId,
|
|
||||||
string ClipUrl,
|
|
||||||
string Title,
|
|
||||||
string Creator);
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record TimelineItem(
|
||||||
|
string Key,
|
||||||
|
string Title,
|
||||||
|
DateOnly StartsAt,
|
||||||
|
DateOnly EndsAt,
|
||||||
|
string State);
|
||||||
|
|
||||||
|
public sealed record FeaturedCategoryDto(
|
||||||
|
int Id,
|
||||||
|
string GroupName,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
int MaxNomineesPerUser);
|
||||||
|
|
||||||
|
public sealed record WinnerPreviewDto(
|
||||||
|
int Year,
|
||||||
|
string CategoryGroup,
|
||||||
|
string Category,
|
||||||
|
string WinnerName,
|
||||||
|
string WinnerSlug,
|
||||||
|
string WinnerPlatform,
|
||||||
|
string WinnerUrl,
|
||||||
|
string? ClipUrl,
|
||||||
|
string? ClipTitle,
|
||||||
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
|
public sealed record ArchiveYearDto(
|
||||||
|
int Year,
|
||||||
|
int WinnerCount);
|
||||||
|
|
||||||
|
public sealed record FaqItemDto(string Question, string Answer);
|
||||||
|
|
||||||
|
public sealed record PublicSocialLinkDto(
|
||||||
|
string Label,
|
||||||
|
string Platform,
|
||||||
|
string Url,
|
||||||
|
string? Icon = null,
|
||||||
|
bool ShowOnHost = true,
|
||||||
|
bool ShowOnCommunity = true);
|
||||||
|
|
||||||
|
public sealed record FooterLinkDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string Url,
|
||||||
|
string Content);
|
||||||
|
|
||||||
|
public sealed record PublicStreamBannerContentDto(
|
||||||
|
string Eyebrow,
|
||||||
|
string Title,
|
||||||
|
string Text,
|
||||||
|
string LiveButtonLabel,
|
||||||
|
string LiveButtonUrl,
|
||||||
|
string LockedButtonLabel,
|
||||||
|
bool UseCompletedContent,
|
||||||
|
string CompletedEyebrow,
|
||||||
|
string CompletedTitle,
|
||||||
|
string CompletedText,
|
||||||
|
string CompletedButtonLabel,
|
||||||
|
string CompletedButtonUrl);
|
||||||
|
|
||||||
|
public sealed record PublicSiteContentDto(
|
||||||
|
string HostDisplayName,
|
||||||
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
|
string HostImageUrl,
|
||||||
|
string NewsletterUrl,
|
||||||
|
string ShareXUrl,
|
||||||
|
string ShareDiscordUrl,
|
||||||
|
string PrivacyEmail,
|
||||||
|
string PrivacyPolicyContent,
|
||||||
|
string AwardsSectionTitle,
|
||||||
|
string AwardsSectionDescription,
|
||||||
|
string SubcategoriesSectionTitle,
|
||||||
|
string SubcategoriesSectionDescription,
|
||||||
|
PublicStreamBannerContentDto StreamBanner,
|
||||||
|
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||||
|
IEnumerable<FooterLinkDto> FooterLinks);
|
||||||
|
|
||||||
|
public sealed record PublicSiteStatusResponse(
|
||||||
|
bool DemoLoginEnabled,
|
||||||
|
bool MaintenanceModeEnabled,
|
||||||
|
string MaintenanceTitle,
|
||||||
|
string MaintenanceMessage);
|
||||||
|
|
||||||
|
public sealed record PublicFeatureFlagsDto(
|
||||||
|
bool ClipSubmissionsEnabled,
|
||||||
|
bool ClipReviewEnabled,
|
||||||
|
string ClipSubmissionDisabledMessage,
|
||||||
|
bool ShowactApplicationsEnabled,
|
||||||
|
DateOnly? ShowactApplicationStartsAt,
|
||||||
|
DateOnly? ShowactApplicationEndsAt,
|
||||||
|
string ShowactApplicationDisabledMessage,
|
||||||
|
bool SponsorsVisible,
|
||||||
|
string ShowactFormSchemaJson);
|
||||||
|
|
||||||
|
public sealed record OverviewResponse(
|
||||||
|
int SeasonId,
|
||||||
|
int Year,
|
||||||
|
string Title,
|
||||||
|
DateOnly ShowDate,
|
||||||
|
TimeOnly ShowStartsAt,
|
||||||
|
string CurrentPhase,
|
||||||
|
bool IsCommunityOnly,
|
||||||
|
string LoginProvider,
|
||||||
|
IEnumerable<TimelineItem> Timeline,
|
||||||
|
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
|
||||||
|
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
||||||
|
IEnumerable<ArchiveYearDto> ArchiveYears,
|
||||||
|
PublicSiteContentDto SiteContent,
|
||||||
|
PublicFeatureFlagsDto FeatureFlags,
|
||||||
|
IEnumerable<FaqItemDto> Faq);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record CandidateSummaryDto(
|
||||||
|
int Id,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string ChannelUrl,
|
||||||
|
string Platform,
|
||||||
|
string? ClipUrl,
|
||||||
|
string? ClipTitle,
|
||||||
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
|
public sealed record PublicCategoryDetailDto(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string GroupName,
|
||||||
|
string Description,
|
||||||
|
int MaxNomineesPerUser,
|
||||||
|
IEnumerable<CandidateSummaryDto> Candidates);
|
||||||
|
|
||||||
|
public sealed record SeasonCategoriesResponse(
|
||||||
|
int SeasonId,
|
||||||
|
int Year,
|
||||||
|
IEnumerable<PublicCategoryDetailDto> Categories);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record UserNominationStateDto(
|
||||||
|
int? CategoryId,
|
||||||
|
string CategoryGroupName,
|
||||||
|
string[] Nominees);
|
||||||
|
|
||||||
|
public sealed record UserVoteStateDto(
|
||||||
|
int CategoryId,
|
||||||
|
int CandidateId);
|
||||||
|
|
||||||
|
public sealed record UserClipSubmissionStateDto(
|
||||||
|
int Id,
|
||||||
|
int? CategoryId,
|
||||||
|
string ClipUrl,
|
||||||
|
string Title,
|
||||||
|
string Creator,
|
||||||
|
string Platform,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string? ReviewNote,
|
||||||
|
DateTimeOffset? ReviewedAt);
|
||||||
|
|
||||||
|
public sealed record UserParticipationResponse(
|
||||||
|
int SeasonId,
|
||||||
|
int Year,
|
||||||
|
UserNominationStateDto[] Nominations,
|
||||||
|
UserVoteStateDto[] Votes,
|
||||||
|
UserClipSubmissionStateDto[] ClipSubmissions);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record WinnerArchiveItemDto(
|
||||||
|
string CategoryGroup,
|
||||||
|
string Category,
|
||||||
|
string WinnerName,
|
||||||
|
string WinnerSlug,
|
||||||
|
string WinnerPlatform,
|
||||||
|
string WinnerUrl,
|
||||||
|
string? ClipUrl,
|
||||||
|
string? ClipTitle,
|
||||||
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
|
public sealed record WinnerArchiveResponse(
|
||||||
|
int Year,
|
||||||
|
IEnumerable<WinnerArchiveItemDto> Items);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
|
public sealed record NominationEntryRequest(
|
||||||
|
string? Name,
|
||||||
|
string StreamUrl);
|
||||||
|
|
||||||
|
public sealed record CreateNominationRequest(
|
||||||
|
int Year,
|
||||||
|
int? CategoryId,
|
||||||
|
string? CategoryGroupName,
|
||||||
|
string TwitchUserId,
|
||||||
|
string[]? Nominees,
|
||||||
|
NominationEntryRequest[]? Nominations);
|
||||||
|
|
||||||
|
public sealed record VoteEntryRequest(
|
||||||
|
int CategoryId,
|
||||||
|
int CandidateId);
|
||||||
|
|
||||||
|
public sealed record CreateVoteRequest(
|
||||||
|
int SeasonId,
|
||||||
|
string TwitchUserId,
|
||||||
|
VoteEntryRequest[] Entries);
|
||||||
|
|
||||||
|
public sealed record CreateClipRequest(
|
||||||
|
int Year,
|
||||||
|
int? CategoryId,
|
||||||
|
int? CandidateId,
|
||||||
|
string TwitchUserId,
|
||||||
|
string ClipUrl,
|
||||||
|
string Title,
|
||||||
|
string Creator);
|
||||||
@@ -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,11 @@ 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<TeamMember> TeamMembers => Set<TeamMember>();
|
||||||
|
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -23,7 +30,83 @@ 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.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 =>
|
||||||
|
{
|
||||||
|
entity.Property(item => item.HostDisplayName).HasMaxLength(120);
|
||||||
|
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.PrivacyEmail).HasMaxLength(160);
|
||||||
|
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.ImprintUrl).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.ContactUrl).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.SponsorsUrl).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.ShowactsUrl).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.ShowactsContent).HasDefaultValue(string.Empty);
|
||||||
|
entity.Property(item => item.StreamBannerEyebrow).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.StreamBannerTitle).HasMaxLength(160);
|
||||||
|
entity.Property(item => item.StreamBannerLiveButtonLabel).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.StreamBannerLiveButtonUrl).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.StreamBannerLockedButtonLabel).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.StreamBannerCompletedEyebrow).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.StreamBannerCompletedTitle).HasMaxLength(160);
|
||||||
|
entity.Property(item => item.StreamBannerCompletedButtonLabel).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.StreamBannerCompletedButtonUrl).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.DemoLoginEmail).HasMaxLength(180);
|
||||||
|
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.DemoLoginTwitchUserId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.DemoLoginDisplayName).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.TwitchClientId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
|
||||||
|
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.TwitchScope).HasMaxLength(300);
|
||||||
|
entity.Property(item => item.SessionIdleTimeoutHours).HasDefaultValue(3);
|
||||||
|
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
||||||
|
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.TrackingRulesJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.ViewerStatsProviderBaseUrl).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
|
||||||
|
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
|
||||||
|
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
|
||||||
|
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
|
||||||
|
entity.Property(item => item.ShowactApplicationStartsAt);
|
||||||
|
entity.Property(item => item.ShowactApplicationEndsAt);
|
||||||
|
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
|
||||||
|
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<TeamMember>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(item => item.Login).IsUnique();
|
||||||
|
entity.HasIndex(item => item.BoundTwitchUserId).IsUnique();
|
||||||
|
entity.Property(item => item.Login).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.Role).HasMaxLength(40);
|
||||||
|
entity.Property(item => item.PasswordHash).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.PasswordSalt).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.BoundTwitchUserId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.BoundTwitchDisplayName).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.CreatedByTwitchId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.UpdatedByTwitchId).HasMaxLength(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<TeamRolePermission>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(item => item.Role).IsUnique();
|
||||||
|
entity.Property(item => item.Role).HasMaxLength(40);
|
||||||
|
entity.Property(item => item.UpdatedByTwitchId).HasMaxLength(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Category>(entity =>
|
modelBuilder.Entity<Category>(entity =>
|
||||||
@@ -32,32 +115,90 @@ 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.ResolvedChannel).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.ResolvedPlatform).HasMaxLength(40);
|
||||||
|
entity.Property(item => item.HoursStreamed);
|
||||||
|
entity.Property(item => item.HoursWatched);
|
||||||
|
entity.Property(item => item.PeakViewers);
|
||||||
|
entity.Property(item => item.FollowersGained);
|
||||||
|
entity.Property(item => item.TrackerStatus).HasMaxLength(40).HasDefaultValue("pending");
|
||||||
|
entity.Property(item => item.TrackingReviewStatus).HasMaxLength(30).HasDefaultValue("clear");
|
||||||
|
entity.Property(item => item.TrackingFlagsJson).HasDefaultValue("[]");
|
||||||
|
entity.Property(item => item.TrackingReviewNote).HasMaxLength(1000);
|
||||||
|
entity.Property(item => item.TrackingReviewedByTwitchId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.Status).HasMaxLength(20);
|
||||||
|
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||||
|
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||||
|
entity.HasIndex(item => new { item.SeasonId, item.Status });
|
||||||
|
entity.HasIndex(item => new { item.SeasonId, item.CategoryGroupName, item.Status });
|
||||||
|
entity.HasIndex(item => new { item.SeasonId, item.StreamerIdentityId, item.CategoryGroupName });
|
||||||
|
entity.HasOne(item => item.Category)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(item => item.SuggestedCategory)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.SuggestedCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<VoteBallot>(entity =>
|
modelBuilder.Entity<VoteBallot>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.HasIndex(item => new { item.SeasonId, item.SubmittedByTwitchId }).IsUnique();
|
||||||
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
||||||
entity.Property(item => item.Status).HasMaxLength(30);
|
entity.Property(item => item.Status).HasMaxLength(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<AwardResult>(entity =>
|
modelBuilder.Entity<AwardResult>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.HasIndex(item => new { item.SeasonId, item.CategoryId }).IsUnique();
|
||||||
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();
|
||||||
@@ -79,6 +220,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.Summary).HasMaxLength(240);
|
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||||
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.ReviewNote).HasMaxLength(500);
|
||||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +231,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.EntityType).HasMaxLength(80);
|
entity.Property(item => item.EntityType).HasMaxLength(80);
|
||||||
entity.Property(item => item.EntityId).HasMaxLength(120);
|
entity.Property(item => item.EntityId).HasMaxLength(120);
|
||||||
entity.Property(item => item.Summary).HasMaxLength(240);
|
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||||
|
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ClipSubmission>(entity =>
|
modelBuilder.Entity<ClipSubmission>(entity =>
|
||||||
@@ -99,10 +243,47 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.Creator).HasMaxLength(120);
|
entity.Property(item => item.Creator).HasMaxLength(120);
|
||||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
entity.Property(item => item.Platform).HasMaxLength(40);
|
||||||
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.ReviewedByTwitchId).HasMaxLength(120);
|
||||||
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.HasOne<Season>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.SeasonId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(item => item.Candidate)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.CandidateId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
SeedData.Apply(modelBuilder);
|
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,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Backend.Data;
|
namespace Backend.Data;
|
||||||
|
|
||||||
@@ -7,9 +8,24 @@ public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<Awa
|
|||||||
{
|
{
|
||||||
public AwardsDbContext CreateDbContext(string[] args)
|
public AwardsDbContext CreateDbContext(string[] args)
|
||||||
{
|
{
|
||||||
|
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
|
.AddJsonFile("appsettings.json", optional: false)
|
||||||
|
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
var optionsBuilder = new DbContextOptionsBuilder<AwardsDbContext>();
|
var optionsBuilder = new DbContextOptionsBuilder<AwardsDbContext>();
|
||||||
var connectionString = Environment.GetEnvironmentVariable("VTSA_POSTGRES")
|
var connectionString = configuration["VTSA_POSTGRES"]
|
||||||
?? "Host=localhost;Port=5432;Database=vtuber_star_awards;Username=postgres;Password=postgres";
|
?? configuration.GetConnectionString("Postgres");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"No PostgreSQL connection string configured for design-time EF operations. " +
|
||||||
|
"Set VTSA_POSTGRES or ConnectionStrings__Postgres before running dotnet ef.");
|
||||||
|
}
|
||||||
|
|
||||||
optionsBuilder.UseNpgsql(connectionString);
|
optionsBuilder.UseNpgsql(connectionString);
|
||||||
return new AwardsDbContext(optionsBuilder.Options);
|
return new AwardsDbContext(optionsBuilder.Options);
|
||||||
|
|||||||
@@ -1,70 +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 '';
|
|
||||||
|
|
||||||
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,
|
|
||||||
"ReviewedByTwitchId" character varying(120) NULL,
|
|
||||||
"CreatedAt" timestamp with time zone NOT NULL,
|
|
||||||
"ReviewedAt" timestamp with time zone 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,
|
|
||||||
"CreatedAt" timestamp with time zone NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
|
|
||||||
ON "AdminAuditEntries" ("CreatedAt" DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
|
|
||||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
||||||
"SeasonId" integer NOT NULL,
|
|
||||||
"CategoryId" integer NULL,
|
|
||||||
"SubmittedByTwitchId" character varying(120) NOT NULL,
|
|
||||||
"ClipUrl" character varying(500) NOT NULL,
|
|
||||||
"Title" character varying(200) NOT NULL,
|
|
||||||
"Creator" character varying(120) NOT NULL,
|
|
||||||
"Platform" character varying(40) NOT NULL,
|
|
||||||
"Status" character varying(20) NOT NULL,
|
|
||||||
"CreatedFromIp" character varying(80) NOT NULL,
|
|
||||||
"CreatedAt" timestamp with time zone NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
|
|
||||||
ON "ClipSubmissions" ("SeasonId", "Status");
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
namespace Backend.Data;
|
||||||
|
|
||||||
|
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 WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
|
||||||
|
internal sealed record SiteFaqSeed(string Question, string Answer);
|
||||||
|
internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon);
|
||||||
|
internal sealed record SponsorSeed(string Name, string WebsiteUrl, string LogoUrl, string Description, string Tier, int SortOrder);
|
||||||
|
|
||||||
|
internal static class SeedCatalog
|
||||||
|
{
|
||||||
|
internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates =
|
||||||
|
[
|
||||||
|
new("Hidden Star", "hidden-star", 1, 1, 20),
|
||||||
|
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("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
|
||||||
|
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
|
||||||
|
new("Entertainment", "Best Variety", "best-variety", "Talk, Comedy, Watchalongs und kreative Streamformate.", 6),
|
||||||
|
new("Community", "Community Liebling", "community-liebling", "Creator:innen, die ihre Community besonders stark verbinden.", 7),
|
||||||
|
new("Collab", "Best Collab & Duo", "best-collab-duo", "Gemeinsame Streams, Projekte und Duo-Dynamik.", 8),
|
||||||
|
];
|
||||||
|
|
||||||
|
internal static readonly Dictionary<string, string> LegacyCategorySlugMap = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["bestes-live-event"] = "best-newcomer",
|
||||||
|
["clip-des-jahres"] = "model-design",
|
||||||
|
["beste-community"] = "gesang-musik",
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static readonly SiteFaqSeed[] SiteFaqSeeds =
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
"Wer darf nominiert werden?",
|
||||||
|
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
|
||||||
|
new(
|
||||||
|
"Wie funktioniert das Voting?",
|
||||||
|
"Du meldest dich 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(
|
||||||
|
"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 für Fans."),
|
||||||
|
new(
|
||||||
|
"Wann und wo findet die Award-Show statt?",
|
||||||
|
"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(
|
||||||
|
"Ich wurde nominiert — was nun?",
|
||||||
|
"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 =
|
||||||
|
[
|
||||||
|
new("Twitch", "twitch", "https://twitch.tv/jayuhime", "twitch"),
|
||||||
|
new("YouTube", "youtube", "https://youtube.com/c/Jayuhime", "youtube"),
|
||||||
|
new("X", "x", "https://x.com/jayuhime", "x"),
|
||||||
|
new("Instagram", "instagram", "https://instagram.com/jayuhime", "instagram"),
|
||||||
|
new("Discord", "discord", "https://discord.gg/jayuhime", "discord"),
|
||||||
|
];
|
||||||
|
|
||||||
|
internal const string DefaultImprintContent = """
|
||||||
|
Anbieter
|
||||||
|
VTuber Star Awards, vertreten durch Jayuhime.
|
||||||
|
|
||||||
|
Kontakt
|
||||||
|
Nutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
|
||||||
|
|
||||||
|
Hinweis
|
||||||
|
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal const string DefaultContactContent = """
|
||||||
|
Kontakt zum Award-Team
|
||||||
|
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.
|
||||||
|
|
||||||
|
Datenschutzfragen
|
||||||
|
Für Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
|
||||||
|
|
||||||
|
Community & Kooperationen
|
||||||
|
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal const string DefaultSponsorsContent = """
|
||||||
|
Sponsoren & Partner
|
||||||
|
Hier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
|
||||||
|
|
||||||
|
Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
||||||
|
[
|
||||||
|
new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||||
|
new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
|
new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
|
new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
|
new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||||
|
new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||||
|
new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||||
|
new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||||
|
new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||||
|
new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
|
new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"),
|
||||||
|
new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||||
|
new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||||
|
new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
|
new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||||
|
new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"),
|
||||||
|
new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"),
|
||||||
|
];
|
||||||
|
|
||||||
|
internal static readonly WinnerSeed[] WinnerSeeds =
|
||||||
|
[
|
||||||
|
new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||||
|
new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
|
new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
|
||||||
|
new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
|
||||||
|
new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
|
||||||
|
new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
|
||||||
|
new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
|
||||||
|
new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"),
|
||||||
|
new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"),
|
||||||
|
new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"),
|
||||||
|
new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
|
||||||
|
new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
|
||||||
|
new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"),
|
||||||
|
new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
|
||||||
|
new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"),
|
||||||
|
new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
|
||||||
|
new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"),
|
||||||
|
new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
|
||||||
|
new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"),
|
||||||
|
new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"),
|
||||||
|
new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"),
|
||||||
|
new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"),
|
||||||
|
new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
|
||||||
|
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,125 +0,0 @@
|
|||||||
using Backend.Domain;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Backend.Data;
|
|
||||||
|
|
||||||
public static class SeedData
|
|
||||||
{
|
|
||||||
public static void Apply(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
modelBuilder.Entity<Season>().HasData(
|
|
||||||
new Season
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
Year = 2026,
|
|
||||||
Name = "VTuber Star Awards 2026",
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
new Season
|
|
||||||
{
|
|
||||||
Id = 2,
|
|
||||||
Year = 2025,
|
|
||||||
Name = "VTuber Star Awards 2025",
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
new Season
|
|
||||||
{
|
|
||||||
Id = 3,
|
|
||||||
Year = 2024,
|
|
||||||
Name = "VTuber Star Awards 2024",
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
new Season
|
|
||||||
{
|
|
||||||
Id = 4,
|
|
||||||
Year = 2023,
|
|
||||||
Name = "VTuber Star Awards 2023",
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
||||||
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, CandidateId = 8, CategoryName = "VTuber des Jahres" },
|
|
||||||
new AwardResult { Id = 2, SeasonId = 2, CandidateId = 9, CategoryName = "Bestes Live Event" },
|
|
||||||
new AwardResult { Id = 3, SeasonId = 2, CandidateId = 10, CategoryName = "Clip des Jahres" },
|
|
||||||
new AwardResult { Id = 4, SeasonId = 3, CandidateId = 11, CategoryName = "VTuber des Jahres" },
|
|
||||||
new AwardResult { Id = 5, SeasonId = 3, CandidateId = 12, CategoryName = "Clip des Jahres" },
|
|
||||||
new AwardResult { Id = 6, SeasonId = 4, 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,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,186 @@
|
|||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Security;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Data;
|
||||||
|
|
||||||
|
public static class TeamAccountBootstrapper
|
||||||
|
{
|
||||||
|
private const string SeedActor = "system:team-seed";
|
||||||
|
|
||||||
|
public static async Task EnsureAsync(AwardsDbContext db, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var ownerSeed = BuildSeed(
|
||||||
|
configuration,
|
||||||
|
sectionName: "Owner",
|
||||||
|
environmentPrefix: "VTSA_TEAM_OWNER",
|
||||||
|
defaultLogin: "jayuhime",
|
||||||
|
defaultDisplayName: "Jayuhime",
|
||||||
|
role: AdminRoles.Owner,
|
||||||
|
fallbackPassword: null);
|
||||||
|
|
||||||
|
var creatorSeed = BuildSeed(
|
||||||
|
configuration,
|
||||||
|
sectionName: "Creator",
|
||||||
|
environmentPrefix: "VTSA_TEAM_CREATOR",
|
||||||
|
defaultLogin: "sleepy_bao",
|
||||||
|
defaultDisplayName: "sleepy_bao",
|
||||||
|
role: AdminRoles.Creator,
|
||||||
|
fallbackPassword: null);
|
||||||
|
|
||||||
|
foreach (var seed in new[] { ownerSeed, creatorSeed })
|
||||||
|
{
|
||||||
|
await EnsureMemberAsync(db, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DeactivateDemoBackedSeedAccountAsync(
|
||||||
|
db,
|
||||||
|
ownerSeed,
|
||||||
|
configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureMemberAsync(AwardsDbContext db, TeamAccountSeed seed)
|
||||||
|
{
|
||||||
|
if (seed.Credentials is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
db.TeamMembers.Add(new TeamMember
|
||||||
|
{
|
||||||
|
Login = seed.Login,
|
||||||
|
DisplayName = seed.DisplayName,
|
||||||
|
Role = seed.Role,
|
||||||
|
PasswordHash = seed.Credentials.Value.Hash,
|
||||||
|
PasswordSalt = seed.Credentials.Value.Salt,
|
||||||
|
MustChangePassword = false,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedByTwitchId = SeedActor,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
if (!string.Equals(member.DisplayName, seed.DisplayName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
member.DisplayName = seed.DisplayName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(member.Role, seed.Role, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
member.Role = seed.Role;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member.IsActive)
|
||||||
|
{
|
||||||
|
member.IsActive = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
member.PasswordHash = seed.Credentials.Value.Hash;
|
||||||
|
member.PasswordSalt = seed.Credentials.Value.Salt;
|
||||||
|
member.MustChangePassword = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = SeedActor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeactivateDemoBackedSeedAccountAsync(AwardsDbContext db, TeamAccountSeed seed, string? demoPassword)
|
||||||
|
{
|
||||||
|
if (seed.Credentials is not null || string.IsNullOrWhiteSpace(demoPassword))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login);
|
||||||
|
if (member is null
|
||||||
|
|| member.CreatedByTwitchId != SeedActor
|
||||||
|
|| !DemoCredentialHasher.VerifyPassword(demoPassword, member.PasswordHash, member.PasswordSalt))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
member.IsActive = false;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = SeedActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamAccountSeed BuildSeed(
|
||||||
|
IConfiguration configuration,
|
||||||
|
string sectionName,
|
||||||
|
string environmentPrefix,
|
||||||
|
string defaultLogin,
|
||||||
|
string defaultDisplayName,
|
||||||
|
string role,
|
||||||
|
string? fallbackPassword)
|
||||||
|
{
|
||||||
|
var login = ReadSetting(configuration, sectionName, environmentPrefix, "Login") ?? defaultLogin;
|
||||||
|
var displayName = ReadSetting(configuration, sectionName, environmentPrefix, "DisplayName") ?? defaultDisplayName;
|
||||||
|
|
||||||
|
return new TeamAccountSeed(
|
||||||
|
NormalizeLogin(login),
|
||||||
|
displayName.Trim(),
|
||||||
|
role,
|
||||||
|
ResolveCredentials(configuration, sectionName, environmentPrefix, fallbackPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamSeedCredentials? ResolveCredentials(
|
||||||
|
IConfiguration configuration,
|
||||||
|
string sectionName,
|
||||||
|
string environmentPrefix,
|
||||||
|
string? fallbackPassword)
|
||||||
|
{
|
||||||
|
var configuredHash = ReadSetting(configuration, sectionName, environmentPrefix, "PasswordHash");
|
||||||
|
var configuredSalt = ReadSetting(configuration, sectionName, environmentPrefix, "PasswordSalt");
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredHash) && !string.IsNullOrWhiteSpace(configuredSalt))
|
||||||
|
{
|
||||||
|
return new TeamSeedCredentials(configuredHash.Trim(), configuredSalt.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
var password = ReadSetting(configuration, sectionName, environmentPrefix, "Password") ?? fallbackPassword;
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials = DemoCredentialHasher.HashPassword(password);
|
||||||
|
return new TeamSeedCredentials(credentials.Hash, credentials.Salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadSetting(IConfiguration configuration, string sectionName, string environmentPrefix, string key)
|
||||||
|
{
|
||||||
|
var raw = configuration[$"{environmentPrefix}_{ToEnvironmentKey(key)}"]
|
||||||
|
?? configuration[$"TeamSeed:{sectionName}:{key}"];
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(raw) ? null : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToEnvironmentKey(string key)
|
||||||
|
{
|
||||||
|
return string.Concat(key.Select((character, index) =>
|
||||||
|
index > 0 && char.IsUpper(character) ? $"_{character}" : character.ToString())).ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLogin(string value) =>
|
||||||
|
value.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|
||||||
|
private sealed record TeamAccountSeed(string Login, string DisplayName, string Role, TeamSeedCredentials? Credentials);
|
||||||
|
|
||||||
|
private readonly record struct TeamSeedCredentials(string Hash, string Salt);
|
||||||
|
}
|
||||||
@@ -9,5 +9,7 @@ public sealed class AdminAuditEntry
|
|||||||
public string EntityId { get; set; } = string.Empty;
|
public string EntityId { get; set; } = string.Empty;
|
||||||
public string Summary { get; set; } = string.Empty;
|
public string Summary { get; set; } = string.Empty;
|
||||||
public string MetadataJson { get; set; } = "{}";
|
public string MetadataJson { get; set; } = "{}";
|
||||||
|
public string CreatedFromIp { get; set; } = string.Empty;
|
||||||
|
public string UserAgent { get; set; } = string.Empty;
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ public sealed class AwardResult
|
|||||||
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 Category Category { get; set; } = null!;
|
||||||
public int CandidateId { get; set; }
|
public int CandidateId { get; set; }
|
||||||
public Candidate Candidate { get; set; } = null!;
|
public Candidate Candidate { get; set; } = null!;
|
||||||
public string CategoryName { get; set; } = string.Empty;
|
public string CategoryName { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -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,12 +5,17 @@ public sealed class ClipSubmission
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int SeasonId { get; set; }
|
public int SeasonId { get; set; }
|
||||||
public int? CategoryId { get; set; }
|
public int? CategoryId { get; set; }
|
||||||
|
public int? CandidateId { get; set; }
|
||||||
|
public Candidate? Candidate { get; set; }
|
||||||
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
public string SubmittedByTwitchId { get; set; } = string.Empty;
|
||||||
public string ClipUrl { get; set; } = string.Empty;
|
public string ClipUrl { get; set; } = string.Empty;
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Creator { get; set; } = string.Empty;
|
public string Creator { get; set; } = string.Empty;
|
||||||
public string Platform { get; set; } = string.Empty;
|
public string Platform { get; set; } = string.Empty;
|
||||||
public string Status { get; set; } = "pending";
|
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 CreatedFromIp { get; set; } = string.Empty;
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,35 @@ 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? ResolvedChannel { get; set; }
|
||||||
|
public string? ResolvedPlatform { get; set; }
|
||||||
|
public int? AvgViewers { get; set; }
|
||||||
|
public int? HoursStreamed { get; set; }
|
||||||
|
public int? HoursWatched { get; set; }
|
||||||
|
public int? PeakViewers { get; set; }
|
||||||
|
public int? FollowersGained { get; set; }
|
||||||
|
public string TrackerStatus { get; set; } = "pending";
|
||||||
|
public DateTimeOffset? TrackerCheckedAt { get; set; }
|
||||||
|
public string TrackingReviewStatus { get; set; } = "clear";
|
||||||
|
public string TrackingFlagsJson { get; set; } = "[]";
|
||||||
|
public string? TrackingReviewNote { get; set; }
|
||||||
|
public string? TrackingReviewedByTwitchId { get; set; }
|
||||||
|
public DateTimeOffset? TrackingReviewedAt { get; set; }
|
||||||
|
public string Status { get; set; } = "pending";
|
||||||
|
public string? ReviewNote { get; set; }
|
||||||
|
public string? ReviewedByTwitchId { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class RiskFlag
|
|||||||
public string CreatedFromIp { get; set; } = string.Empty;
|
public string CreatedFromIp { get; set; } = string.Empty;
|
||||||
public string UserAgent { get; set; } = string.Empty;
|
public string UserAgent { get; set; } = string.Empty;
|
||||||
public string MetadataJson { get; set; } = "{}";
|
public string MetadataJson { get; set; } = "{}";
|
||||||
|
public string? ReviewNote { get; set; }
|
||||||
public string? ReviewedByTwitchId { get; set; }
|
public string? ReviewedByTwitchId { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
public DateTimeOffset? ReviewedAt { get; set; }
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
|
|||||||
@@ -5,6 +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 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;
|
||||||
@@ -15,6 +16,11 @@ public sealed class Season
|
|||||||
public DateOnly ReviewStartsAt { get; set; }
|
public DateOnly ReviewStartsAt { get; set; }
|
||||||
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 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class SiteSettings
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string HostDisplayName { 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 ShareXUrl { get; set; } = string.Empty;
|
||||||
|
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||||
|
public string PrivacyEmail { get; set; } = string.Empty;
|
||||||
|
public string PrivacyPolicyContent { get; set; } = string.Empty;
|
||||||
|
public string? PrivacyPolicyUpdatedBy { get; set; }
|
||||||
|
public DateTimeOffset? PrivacyPolicyUpdatedAt { get; set; }
|
||||||
|
public string ImprintUrl { get; set; } = string.Empty;
|
||||||
|
public string ImprintContent { get; set; } = string.Empty;
|
||||||
|
public string ContactUrl { get; set; } = string.Empty;
|
||||||
|
public string ContactContent { get; set; } = string.Empty;
|
||||||
|
public string SponsorsUrl { get; set; } = string.Empty;
|
||||||
|
public string SponsorsContent { get; set; } = string.Empty;
|
||||||
|
public string ShowactsUrl { get; set; } = string.Empty;
|
||||||
|
public string ShowactsContent { get; set; } = string.Empty;
|
||||||
|
public string StreamBannerEyebrow { get; set; } = "Das grosse Finale";
|
||||||
|
public string StreamBannerTitle { get; set; } = "Award-Show Finale";
|
||||||
|
public string StreamBannerText { get; set; } = string.Empty;
|
||||||
|
public string StreamBannerLiveButtonLabel { get; set; } = "Jetzt live · Zum Stream";
|
||||||
|
public string StreamBannerLiveButtonUrl { get; set; } = string.Empty;
|
||||||
|
public string StreamBannerLockedButtonLabel { get; set; } = "Stream noch gesperrt";
|
||||||
|
public bool StreamBannerUseCompletedContent { get; set; }
|
||||||
|
public string StreamBannerCompletedEyebrow { get; set; } = "Danke fürs Mitfiebern";
|
||||||
|
public string StreamBannerCompletedTitle { get; set; } = "Award-Show abgeschlossen";
|
||||||
|
public string StreamBannerCompletedText { get; set; } = "Die grosse Award-Show ist vorbei. Danke an alle, die live dabei waren.";
|
||||||
|
public string StreamBannerCompletedButtonLabel { get; set; } = "Highlights ansehen";
|
||||||
|
public string StreamBannerCompletedButtonUrl { get; set; } = string.Empty;
|
||||||
|
public string AwardsSectionTitle { get; set; } = string.Empty;
|
||||||
|
public string AwardsSectionDescription { get; set; } = string.Empty;
|
||||||
|
public string SubcategoriesSectionTitle { get; set; } = string.Empty;
|
||||||
|
public string SubcategoriesSectionDescription { get; set; } = string.Empty;
|
||||||
|
public string SocialLinksJson { get; set; } = "[]";
|
||||||
|
public string FaqJson { get; set; } = "[]";
|
||||||
|
public string RiskRulesJson { get; set; } = "[]";
|
||||||
|
public string WorkflowRulesJson { get; set; } = "[]";
|
||||||
|
public string TrackingRulesJson { get; set; } = "[]";
|
||||||
|
public string ViewerStatsProviderBaseUrl { get; set; } = string.Empty;
|
||||||
|
public string TrackingReviewNotes { get; set; } = string.Empty;
|
||||||
|
public string NominationLinkBlacklistJson { get; set; } = "[]";
|
||||||
|
public bool ClipSubmissionsEnabled { get; set; }
|
||||||
|
public bool ClipReviewEnabled { get; set; } = true;
|
||||||
|
public bool ClipAdminMenuVisible { get; set; } = true;
|
||||||
|
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
|
||||||
|
public bool ShowactApplicationsEnabled { get; set; }
|
||||||
|
public DateOnly? ShowactApplicationStartsAt { get; set; }
|
||||||
|
public DateOnly? ShowactApplicationEndsAt { get; set; }
|
||||||
|
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
|
||||||
|
public string ShowactFormSchemaJson { get; set; } = "[]";
|
||||||
|
public bool SponsorsVisible { get; set; } = true;
|
||||||
|
public bool DemoLoginManagedByDatabase { get; set; }
|
||||||
|
public bool DemoLoginEnabled { get; set; }
|
||||||
|
public string DemoLoginEmail { get; set; } = string.Empty;
|
||||||
|
public string DemoLoginPasswordHash { get; set; } = string.Empty;
|
||||||
|
public string DemoLoginPasswordSalt { get; set; } = string.Empty;
|
||||||
|
public string DemoLoginTwitchUserId { get; set; } = "jayuhime_admin";
|
||||||
|
public string DemoLoginDisplayName { get; set; } = "Jayuhime Admin";
|
||||||
|
public bool TwitchAuthManagedByDatabase { get; set; }
|
||||||
|
public string TwitchClientId { get; set; } = string.Empty;
|
||||||
|
public string TwitchClientSecret { get; set; } = string.Empty;
|
||||||
|
public string TwitchRedirectUri { get; set; } = string.Empty;
|
||||||
|
public string TwitchScope { get; set; } = string.Empty;
|
||||||
|
public int SessionIdleTimeoutHours { get; set; } = 3;
|
||||||
|
public bool MaintenanceModeEnabled { get; set; }
|
||||||
|
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
||||||
|
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class Sponsor
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int SeasonId { get; set; }
|
||||||
|
public Season Season { get; set; } = null!;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string WebsiteUrl { get; set; } = string.Empty;
|
||||||
|
public string LogoUrl { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Tier { get; set; } = "Partner";
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsVisible { get; set; } = true;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class StreamerIdentity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Platform { get; set; } = string.Empty;
|
||||||
|
public string Login { get; set; } = string.Empty;
|
||||||
|
public string NormalizedKey { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string? ProfileUrl { get; set; }
|
||||||
|
public DateTimeOffset? LastResolvedAt { get; set; }
|
||||||
|
public ICollection<Nomination> Nominations { get; set; } = [];
|
||||||
|
public ICollection<Candidate> Candidates { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class TeamMember
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Login { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public string PasswordSalt { get; set; } = string.Empty;
|
||||||
|
public string? BoundTwitchUserId { get; set; }
|
||||||
|
public string? BoundTwitchDisplayName { get; set; }
|
||||||
|
public bool MustChangePassword { get; set; } = true;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string CreatedByTwitchId { get; set; } = string.Empty;
|
||||||
|
public string? UpdatedByTwitchId { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset? UpdatedAt { get; set; }
|
||||||
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
|
public DateTimeOffset? TwitchBoundAt { get; set; }
|
||||||
|
public DateTimeOffset? PasswordResetAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class TeamRolePermission
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string PermissionsJson { get; set; } = "[]";
|
||||||
|
public string UpdatedByTwitchId { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset UpdatedAt { 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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminModerationEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> DeleteClip(
|
||||||
|
HttpContext context,
|
||||||
|
int clipId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
|
||||||
|
if (clip is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ClipSubmissions.Remove(clip);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"clip.delete",
|
||||||
|
"clip",
|
||||||
|
clip.Id.ToString(),
|
||||||
|
$"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.",
|
||||||
|
new { clip.Platform },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, clipId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateClipStatus(
|
||||||
|
HttpContext context,
|
||||||
|
int clipId,
|
||||||
|
UpdateClipStatusRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
|
||||||
|
if (clip is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedStatus = string.IsNullOrWhiteSpace(request.Status)
|
||||||
|
? "pending"
|
||||||
|
: request.Status.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (normalizedStatus is not ("pending" or "approved" or "rejected"))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Clip status must be pending, approved or rejected." });
|
||||||
|
}
|
||||||
|
|
||||||
|
clip.Status = normalizedStatus;
|
||||||
|
clip.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||||
|
if (normalizedStatus == "pending")
|
||||||
|
{
|
||||||
|
clip.ReviewedAt = null;
|
||||||
|
clip.ReviewedByTwitchId = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
clip.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
clip.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"clip.status.update",
|
||||||
|
"clip",
|
||||||
|
clip.Id.ToString(),
|
||||||
|
$"Clip-Einreichung {clip.Id} wurde auf {clip.Status} gesetzt.",
|
||||||
|
new { clip.Status, clip.ReviewNote },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, clipId = clip.Id, status = clip.Status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Security;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminDashboardEndpoints
|
||||||
|
{
|
||||||
|
public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/dashboard", GetDashboard)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Dashboard))
|
||||||
|
.WithName("GetAdminDashboard")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapGet("/audit-entries", GetAuditEntries)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Audit))
|
||||||
|
.WithName("GetAdminAuditEntries")
|
||||||
|
.WithOpenApi();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetDashboard(int? seasonId, AwardsDbContext db, HttpContext context)
|
||||||
|
{
|
||||||
|
var canViewAuditIp = CanViewAuditIp(context);
|
||||||
|
var selectedSeason = seasonId.HasValue
|
||||||
|
? await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.Id == seasonId.Value)
|
||||||
|
: await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
|
||||||
|
if (selectedSeason is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSeasonId = selectedSeason.Id;
|
||||||
|
var phaseKey = SeasonMappings.NormalizePhaseKey(selectedSeason.CurrentPhase);
|
||||||
|
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId);
|
||||||
|
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == selectedSeasonId);
|
||||||
|
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == selectedSeasonId);
|
||||||
|
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == selectedSeasonId && item.Status == "pending");
|
||||||
|
var riskFlagCount = await db.RiskFlags.CountAsync(item =>
|
||||||
|
item.Status == "open" &&
|
||||||
|
(item.SeasonId == selectedSeasonId || item.SeasonId == null));
|
||||||
|
var globalRiskFlagCount = await db.RiskFlags.CountAsync(item =>
|
||||||
|
item.Status == "open" &&
|
||||||
|
item.SeasonId == null);
|
||||||
|
|
||||||
|
var topCategories = phaseKey == "nomination"
|
||||||
|
? await BuildTopNominationCategoriesAsync(db, selectedSeasonId)
|
||||||
|
: await BuildTopVotingCategoriesAsync(db, selectedSeasonId);
|
||||||
|
|
||||||
|
var riskFlags = await db.RiskFlags
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.Status == "open" &&
|
||||||
|
(item.SeasonId == selectedSeasonId || item.SeasonId == null))
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(8)
|
||||||
|
.ToArrayAsync();
|
||||||
|
var riskFlagDtos = riskFlags.Select(AdminRiskFlagMappings.ToDto).ToArray();
|
||||||
|
|
||||||
|
var auditEntries = await db.AdminAuditEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(8)
|
||||||
|
.Select(item => new AdminAuditEntryDto(
|
||||||
|
item.Id,
|
||||||
|
item.AdminTwitchUserId,
|
||||||
|
item.ActionType,
|
||||||
|
item.EntityType,
|
||||||
|
item.EntityId,
|
||||||
|
item.Summary,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.MetadataJson,
|
||||||
|
canViewAuditIp ? item.CreatedFromIp : null,
|
||||||
|
item.UserAgent))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var activityItems = auditEntries
|
||||||
|
.Take(6)
|
||||||
|
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Results.Ok(new AdminDashboardResponse(
|
||||||
|
selectedSeason.Id,
|
||||||
|
selectedSeason.Year,
|
||||||
|
selectedSeason.Name,
|
||||||
|
selectedSeason.IsCurrent,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new AdminMetricDto("Nominierungen", nominationCount, $"Gespeicherte Einreichungen im Award-Jahr {selectedSeason.Year}"),
|
||||||
|
new AdminMetricDto("Stimmen", voteCount, $"Abgegebene Stimmen im Award-Jahr {selectedSeason.Year}"),
|
||||||
|
new AdminMetricDto("Kategorien", categoryCount, $"Aktive Kategorien im Award-Jahr {selectedSeason.Year}"),
|
||||||
|
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf in diesem Jahr"),
|
||||||
|
new AdminMetricDto(
|
||||||
|
"Risikohinweise",
|
||||||
|
riskFlagCount,
|
||||||
|
globalRiskFlagCount > 0
|
||||||
|
? $"Offene Hinweise fuer {selectedSeason.Year}, inklusive {globalRiskFlagCount} globaler Hinweise"
|
||||||
|
: $"Offene Hinweise fuer {selectedSeason.Year}"),
|
||||||
|
},
|
||||||
|
activityItems,
|
||||||
|
topCategories,
|
||||||
|
riskFlagDtos,
|
||||||
|
auditEntries));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AdminTopCategoryDto[]> BuildTopVotingCategoriesAsync(AwardsDbContext db, int seasonId)
|
||||||
|
{
|
||||||
|
var categoryNames = await db.VoteEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Ballot.SeasonId == seasonId)
|
||||||
|
.Select(item => item.Category.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return categoryNames
|
||||||
|
.GroupBy(name => name)
|
||||||
|
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Stimmen"))
|
||||||
|
.OrderByDescending(item => item.Value)
|
||||||
|
.Take(5)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AdminTopCategoryDto[]> BuildTopNominationCategoriesAsync(AwardsDbContext db, int seasonId)
|
||||||
|
{
|
||||||
|
var nominationCategories = await db.Nominations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
CategoryName = item.Category != null ? item.Category.Name : null,
|
||||||
|
item.CategoryGroupName,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return nominationCategories
|
||||||
|
.Select(item => string.IsNullOrWhiteSpace(item.CategoryName)
|
||||||
|
? string.IsNullOrWhiteSpace(item.CategoryGroupName) ? "Ohne Kategorie" : item.CategoryGroupName
|
||||||
|
: item.CategoryName)
|
||||||
|
.GroupBy(name => name)
|
||||||
|
.Select(grouping => new AdminTopCategoryDto(grouping.Key, grouping.Count(), "Nominierungen"))
|
||||||
|
.OrderByDescending(item => item.Value)
|
||||||
|
.Take(5)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAuditEntries(
|
||||||
|
int? limit,
|
||||||
|
string? query,
|
||||||
|
string? admin,
|
||||||
|
string? action,
|
||||||
|
string? entityType,
|
||||||
|
DateTimeOffset? from,
|
||||||
|
DateTimeOffset? to,
|
||||||
|
string? cursor,
|
||||||
|
AwardsDbContext db,
|
||||||
|
HttpContext context)
|
||||||
|
{
|
||||||
|
var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500);
|
||||||
|
var search = query?.Trim();
|
||||||
|
var canViewAuditIp = CanViewAuditIp(context);
|
||||||
|
var auditQuery = db.AdminAuditEntries.AsNoTracking();
|
||||||
|
|
||||||
|
if (!TryDecodeAuditCursor(cursor, out var decodedCursor))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Invalid audit cursor." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var pattern = $"%{search}%";
|
||||||
|
auditQuery = auditQuery.Where(item =>
|
||||||
|
EF.Functions.ILike(item.AdminTwitchUserId, pattern) ||
|
||||||
|
EF.Functions.ILike(item.ActionType, pattern) ||
|
||||||
|
EF.Functions.ILike(item.EntityType, pattern) ||
|
||||||
|
EF.Functions.ILike(item.EntityId, pattern) ||
|
||||||
|
EF.Functions.ILike(item.Summary, pattern) ||
|
||||||
|
EF.Functions.ILike(item.MetadataJson, pattern) ||
|
||||||
|
EF.Functions.ILike(item.UserAgent, pattern) ||
|
||||||
|
(canViewAuditIp && EF.Functions.ILike(item.CreatedFromIp, pattern)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(admin))
|
||||||
|
{
|
||||||
|
var normalizedAdmin = admin.Trim();
|
||||||
|
auditQuery = auditQuery.Where(item => item.AdminTwitchUserId == normalizedAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(action))
|
||||||
|
{
|
||||||
|
var normalizedAction = action.Trim();
|
||||||
|
auditQuery = auditQuery.Where(item => item.ActionType == normalizedAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(entityType))
|
||||||
|
{
|
||||||
|
var normalizedEntityType = entityType.Trim();
|
||||||
|
auditQuery = auditQuery.Where(item => item.EntityType == normalizedEntityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.HasValue)
|
||||||
|
{
|
||||||
|
auditQuery = auditQuery.Where(item => item.CreatedAt >= from.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.HasValue)
|
||||||
|
{
|
||||||
|
auditQuery = auditQuery.Where(item => item.CreatedAt <= to.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await auditQuery.CountAsync();
|
||||||
|
|
||||||
|
if (decodedCursor is not null)
|
||||||
|
{
|
||||||
|
auditQuery = auditQuery.Where(item =>
|
||||||
|
item.CreatedAt < decodedCursor.CreatedAt ||
|
||||||
|
(item.CreatedAt == decodedCursor.CreatedAt && item.Id < decodedCursor.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = await auditQuery
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.ThenByDescending(item => item.Id)
|
||||||
|
.Take(normalizedLimit + 1)
|
||||||
|
.Select(item => new AdminAuditEntryDto(
|
||||||
|
item.Id,
|
||||||
|
item.AdminTwitchUserId,
|
||||||
|
item.ActionType,
|
||||||
|
item.EntityType,
|
||||||
|
item.EntityId,
|
||||||
|
item.Summary,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.MetadataJson,
|
||||||
|
canViewAuditIp ? item.CreatedFromIp : null,
|
||||||
|
item.UserAgent))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var hasMore = page.Length > normalizedLimit;
|
||||||
|
var entries = page.Take(normalizedLimit).ToArray();
|
||||||
|
var nextCursor = hasMore && entries.Length > 0
|
||||||
|
? EncodeAuditCursor(entries[^1])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Results.Ok(new AdminAuditEntriesResponse(
|
||||||
|
entries,
|
||||||
|
totalCount,
|
||||||
|
entries.Length,
|
||||||
|
nextCursor,
|
||||||
|
normalizedLimit));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EncodeAuditCursor(AdminAuditEntryDto entry) =>
|
||||||
|
$"{entry.CreatedAt.UtcTicks}:{entry.Id}";
|
||||||
|
|
||||||
|
private static bool CanViewAuditIp(HttpContext context) =>
|
||||||
|
AdminRoles.IsPrivilegedFullControlRole(context.GetCurrentSession()?.Role);
|
||||||
|
|
||||||
|
private static bool TryDecodeAuditCursor(string? cursor, out AuditCursor? decodedCursor)
|
||||||
|
{
|
||||||
|
decodedCursor = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(cursor))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = cursor.Split(':', 2);
|
||||||
|
if (parts.Length != 2 ||
|
||||||
|
!long.TryParse(parts[0], out var ticks) ||
|
||||||
|
!int.TryParse(parts[1], out var id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
decodedCursor = new AuditCursor(new DateTimeOffset(ticks, TimeSpan.Zero), id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AuditCursor(DateTimeOffset CreatedAt, int Id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Data;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
internal static class AdminEndpointConventions
|
||||||
|
{
|
||||||
|
public static UserSession CurrentSession(HttpContext context) =>
|
||||||
|
context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context.");
|
||||||
|
|
||||||
|
public static async ValueTask<object?> RequirePermission(
|
||||||
|
EndpointFilterInvocationContext context,
|
||||||
|
EndpointFilterDelegate next,
|
||||||
|
string permissionKey)
|
||||||
|
{
|
||||||
|
var session = CurrentSession(context.HttpContext);
|
||||||
|
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
|
||||||
|
if (!await AdminPermissionCatalog.HasPermissionAsync(db, session.Role, permissionKey, context.HttpContext.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = $"Diese Admin-Aktion braucht die Berechtigung '{permissionKey}'." },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<object?> RequireWritePermission(
|
||||||
|
EndpointFilterInvocationContext context,
|
||||||
|
EndpointFilterDelegate next,
|
||||||
|
string permissionKey)
|
||||||
|
{
|
||||||
|
var session = CurrentSession(context.HttpContext);
|
||||||
|
if (AdminRoles.Normalize(session.Role) == AdminRoles.OrganizationTeam)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Organisation Team hat fuer diesen Bereich nur Leserechte." },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await RequirePermission(context, next, permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<object?> RequireAnyPermission(
|
||||||
|
EndpointFilterInvocationContext context,
|
||||||
|
EndpointFilterDelegate next,
|
||||||
|
string[] permissionKeys)
|
||||||
|
{
|
||||||
|
var session = CurrentSession(context.HttpContext);
|
||||||
|
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
|
||||||
|
if (!await AdminPermissionCatalog.HasAnyPermissionAsync(db, session.Role, permissionKeys, context.HttpContext.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Diese Admin-Aktion braucht eine passende Rollenberechtigung." },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Backend.Security;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/admin")
|
||||||
|
.AddEndpointFilter<AdminSessionFilter>();
|
||||||
|
|
||||||
|
group.MapAdminSiteSettingsEndpoints();
|
||||||
|
|
||||||
|
group.MapAdminDashboardEndpoints();
|
||||||
|
group.MapAdminSeasonManagementEndpoints();
|
||||||
|
group.MapAdminArchiveEndpoints();
|
||||||
|
group.MapAdminModerationEndpoints();
|
||||||
|
group.MapAdminExtrasEndpoints();
|
||||||
|
group.MapAdminTeamEndpoints();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminExtrasEndpoints
|
||||||
|
{
|
||||||
|
private static readonly string[] ContentPermissions = ["content", "settings"];
|
||||||
|
private static readonly HashSet<string> AllowedShowactStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"pending",
|
||||||
|
"shortlisted",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static RouteGroupBuilder MapAdminExtrasEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/seasons/{seasonId:int}/showacts", GetShowactApplications)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
|
||||||
|
.WithName("GetAdminShowactApplications")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/showacts/{applicationId:int}/status", UpdateShowactStatus)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||||
|
.WithName("UpdateAdminShowactStatus")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapDelete("/showacts/{applicationId:int}", DeleteShowactApplication)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||||
|
.WithName("DeleteAdminShowactApplication")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/seasons/{seasonId:int}/sponsors", GetSponsors)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
|
||||||
|
.WithName("GetAdminSponsors")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/sponsors", CreateSponsor)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||||
|
.WithName("CreateAdminSponsor")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPut("/sponsors/{sponsorId:int}", UpdateSponsor)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||||
|
.WithName("UpdateAdminSponsor")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapDelete("/sponsors/{sponsorId:int}", DeleteSponsor)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||||
|
.WithName("DeleteAdminSponsor")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetShowactApplications(int seasonId, AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var applications = await db.ShowactApplications
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.Status == "pending" ? 0 : 1)
|
||||||
|
.ThenByDescending(item => item.CreatedAt)
|
||||||
|
.Select(item => ToDto(item))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(applications);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateShowactStatus(
|
||||||
|
HttpContext context,
|
||||||
|
int applicationId,
|
||||||
|
UpdateShowactStatusRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
|
||||||
|
if (application is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = NormalizeStatus(request.Status);
|
||||||
|
if (!AllowedShowactStatuses.Contains(status))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Status muss pending, shortlisted, accepted oder rejected sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var before = new { application.Status, application.ReviewNote };
|
||||||
|
application.Status = status;
|
||||||
|
application.ReviewNote = NormalizeText(request.ReviewNote, 500);
|
||||||
|
application.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
application.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"showact.status",
|
||||||
|
"showact-application",
|
||||||
|
application.Id.ToString(),
|
||||||
|
$"Showact-Bewerbung von {application.ArtistName} wurde auf {status} gesetzt.",
|
||||||
|
new { before, after = new { application.Status, application.ReviewNote } },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, application = ToDto(application) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteShowactApplication(
|
||||||
|
HttpContext context,
|
||||||
|
int applicationId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
|
||||||
|
if (application is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
db.ShowactApplications.Remove(application);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"showact.delete",
|
||||||
|
"showact-application",
|
||||||
|
application.Id.ToString(),
|
||||||
|
$"Showact-Bewerbung von {application.ArtistName} wurde geloescht.",
|
||||||
|
new { application.ArtistName, application.ContactEmail, application.Status },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, applicationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSponsors(int seasonId, AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var sponsors = await db.Sponsors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.Select(item => ToDto(item))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(sponsors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateSponsor(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
UpsertSponsorRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
if (!await db.Seasons.AnyAsync(item => item.Id == seasonId, context.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sponsor = new Sponsor { SeasonId = seasonId, CreatedAt = DateTimeOffset.UtcNow };
|
||||||
|
var validation = ApplySponsorRequest(sponsor, request);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Sponsors.Add(sponsor);
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"sponsor.create",
|
||||||
|
"sponsor",
|
||||||
|
"new",
|
||||||
|
$"Sponsor {sponsor.Name} wurde angelegt.",
|
||||||
|
ToDto(sponsor),
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateSponsor(
|
||||||
|
HttpContext context,
|
||||||
|
int sponsorId,
|
||||||
|
UpsertSponsorRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
|
||||||
|
if (sponsor is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = ToDto(sponsor);
|
||||||
|
var validation = ApplySponsorRequest(sponsor, request);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
sponsor.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"sponsor.update",
|
||||||
|
"sponsor",
|
||||||
|
sponsor.Id.ToString(),
|
||||||
|
$"Sponsor {sponsor.Name} wurde aktualisiert.",
|
||||||
|
new { before, after = ToDto(sponsor) },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteSponsor(
|
||||||
|
HttpContext context,
|
||||||
|
int sponsorId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
|
||||||
|
if (sponsor is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
db.Sponsors.Remove(sponsor);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"sponsor.delete",
|
||||||
|
"sponsor",
|
||||||
|
sponsor.Id.ToString(),
|
||||||
|
$"Sponsor {sponsor.Name} wurde geloescht.",
|
||||||
|
ToDto(sponsor),
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, sponsorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ApplySponsorRequest(Sponsor sponsor, UpsertSponsorRequest request)
|
||||||
|
{
|
||||||
|
var name = NormalizeText(request.Name, 120);
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Sponsor-Name ist erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var websiteUrl = NormalizeText(request.WebsiteUrl, 500);
|
||||||
|
var logoUrl = NormalizeText(request.LogoUrl, 500);
|
||||||
|
if (!IsBlankOrHttpUrl(websiteUrl) || !IsBlankOrHttpUrl(logoUrl))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Sponsor-Links muessen gueltige http(s)-URLs sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
sponsor.Name = name;
|
||||||
|
sponsor.WebsiteUrl = websiteUrl;
|
||||||
|
sponsor.LogoUrl = logoUrl;
|
||||||
|
sponsor.Description = NormalizeText(request.Description, 500);
|
||||||
|
sponsor.Tier = NormalizeText(request.Tier, 80);
|
||||||
|
if (string.IsNullOrWhiteSpace(sponsor.Tier))
|
||||||
|
{
|
||||||
|
sponsor.Tier = "Partner";
|
||||||
|
}
|
||||||
|
|
||||||
|
sponsor.SortOrder = Math.Clamp(request.SortOrder, 0, 9999);
|
||||||
|
sponsor.IsVisible = request.IsVisible;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SponsorDto ToDto(Sponsor sponsor) =>
|
||||||
|
new(
|
||||||
|
sponsor.Id,
|
||||||
|
sponsor.SeasonId,
|
||||||
|
sponsor.Name,
|
||||||
|
sponsor.WebsiteUrl,
|
||||||
|
sponsor.LogoUrl,
|
||||||
|
sponsor.Description,
|
||||||
|
sponsor.Tier,
|
||||||
|
sponsor.SortOrder,
|
||||||
|
sponsor.IsVisible);
|
||||||
|
|
||||||
|
private static ShowactApplicationDto ToDto(ShowactApplication application) =>
|
||||||
|
new(
|
||||||
|
application.Id,
|
||||||
|
application.SeasonId,
|
||||||
|
application.ArtistName,
|
||||||
|
application.ContactEmail,
|
||||||
|
application.ContactDiscord,
|
||||||
|
application.PlatformUrl,
|
||||||
|
application.PerformanceType,
|
||||||
|
application.Description,
|
||||||
|
application.TechnicalNotes,
|
||||||
|
application.ReferenceUrl,
|
||||||
|
application.Status,
|
||||||
|
application.ReviewNote,
|
||||||
|
application.CreatedAt,
|
||||||
|
application.ReviewedAt,
|
||||||
|
application.FieldResponsesJson ?? "{}");
|
||||||
|
|
||||||
|
private static string NormalizeStatus(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeText(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
var trimmed = (value ?? string.Empty).Trim();
|
||||||
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBlankOrHttpUrl(string value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminModerationEndpoints
|
||||||
|
{
|
||||||
|
public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapDelete("/clips/{clipId:int}", DeleteClip)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
|
||||||
|
.WithName("DeleteAdminClip")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
|
||||||
|
.WithName("UpdateAdminClipStatus")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("ApproveAdminNomination")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("RejectAdminNomination")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("ReopenRejectedAdminNomination")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("UpdateNominationTrackingReview")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("GetAdminNominationLinkBlacklist")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/nominations/link-blacklist", UpdateNominationLinkBlacklist)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("UpdateAdminNominationLinkBlacklist")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/nominations/link-blacklist", AddNominationLinkBlacklistEntry)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
|
.WithName("AddAdminNominationLinkBlacklistEntry")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapGet("/risk-flags", GetRiskFlags)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||||
|
.WithName("GetAdminRiskFlags")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||||
|
.WithName("ResolveRiskFlag")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||||
|
.WithName("BulkResolveRiskFlags")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapGet("/risk-rules", GetRiskRules)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||||
|
.WithName("GetAdminRiskRules")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/risk-rules", UpdateRiskRules)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||||
|
.WithName("UpdateAdminRiskRules")
|
||||||
|
.WithOpenApi();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
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 AdminModerationEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> ApproveNomination(
|
||||||
|
HttpContext context,
|
||||||
|
int nominationId,
|
||||||
|
ApproveNominationRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var nomination = await db.Nominations
|
||||||
|
.Include(item => item.Category)
|
||||||
|
.Include(item => item.SuggestedCategory)
|
||||||
|
.Include(item => item.StreamerIdentity)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||||
|
|
||||||
|
if (nomination is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
|
||||||
|
if (!categoryId.HasValue)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == categoryId.Value
|
||||||
|
&& item.SeasonId == nomination.SeasonId
|
||||||
|
&& item.GroupName == nomination.CategoryGroupName);
|
||||||
|
if (targetCategory is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
|
||||||
|
var platform = string.IsNullOrWhiteSpace(request.Platform)
|
||||||
|
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
|
||||||
|
: request.Platform.Trim();
|
||||||
|
var normalizedDisplayName = rawDisplayName.ToLower();
|
||||||
|
var normalizedChannelSlug = channelSlug.ToLower();
|
||||||
|
var normalizedPlatform = platform.ToLower();
|
||||||
|
|
||||||
|
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||||
|
item.SeasonId == nomination.SeasonId
|
||||||
|
&& item.CategoryId == targetCategory.Id
|
||||||
|
&& (
|
||||||
|
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
|
||||||
|
||
|
||||||
|
item.DisplayName.ToLower() == normalizedDisplayName
|
||||||
|
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||||
|
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||||
|
&& item.Platform.ToLower() == normalizedPlatform)
|
||||||
|
));
|
||||||
|
|
||||||
|
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
|
||||||
|
db,
|
||||||
|
nomination.SeasonId,
|
||||||
|
targetCategory.Id,
|
||||||
|
existingCandidate?.Id,
|
||||||
|
nomination.StreamerIdentityId,
|
||||||
|
rawDisplayName,
|
||||||
|
channelSlug,
|
||||||
|
existingCandidate?.AcceptanceStatus ?? "open",
|
||||||
|
context.RequestAborted);
|
||||||
|
if (workflowRuleBlock is not null)
|
||||||
|
{
|
||||||
|
return workflowRuleBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = existingCandidate;
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
candidate = new Candidate
|
||||||
|
{
|
||||||
|
SeasonId = nomination.SeasonId,
|
||||||
|
CategoryId = targetCategory.Id,
|
||||||
|
StreamerIdentityId = nomination.StreamerIdentityId,
|
||||||
|
DisplayName = rawDisplayName,
|
||||||
|
ChannelSlug = channelSlug,
|
||||||
|
Platform = platform,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Candidates.Add(candidate);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
|
||||||
|
candidate.DisplayName = rawDisplayName;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||||
|
{
|
||||||
|
candidate.ChannelSlug = channelSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(platform))
|
||||||
|
{
|
||||||
|
candidate.Platform = platform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||||
|
var uniqueViewerCount = relatedNominations
|
||||||
|
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
|
||||||
|
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
|
||||||
|
|
||||||
|
foreach (var relatedNomination in relatedNominations)
|
||||||
|
{
|
||||||
|
relatedNomination.CandidateId = candidate.Id;
|
||||||
|
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
|
||||||
|
relatedNomination.Status = "approved";
|
||||||
|
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||||
|
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"nomination.approve",
|
||||||
|
"nomination",
|
||||||
|
nomination.Id.ToString(),
|
||||||
|
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
candidateId = candidate.Id,
|
||||||
|
created = existingCandidate is null,
|
||||||
|
targetCategoryId = targetCategory.Id,
|
||||||
|
targetCategoryName = targetCategory.Name,
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
uniqueViewerCount,
|
||||||
|
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
saved = true,
|
||||||
|
nominationId = nomination.Id,
|
||||||
|
candidateId = candidate.Id,
|
||||||
|
created = existingCandidate is null,
|
||||||
|
uniqueViewerCount,
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RejectNomination(
|
||||||
|
HttpContext context,
|
||||||
|
int nominationId,
|
||||||
|
RejectNominationRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId);
|
||||||
|
if (nomination is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
|
||||||
|
foreach (var relatedNomination in relatedNominations)
|
||||||
|
{
|
||||||
|
relatedNomination.CandidateId = null;
|
||||||
|
relatedNomination.Status = "rejected";
|
||||||
|
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
|
||||||
|
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"nomination.reject",
|
||||||
|
"nomination",
|
||||||
|
nomination.Id.ToString(),
|
||||||
|
$"Nominierung {nomination.Id} wurde verworfen.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
|
||||||
|
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminModerationEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetRiskFlags(
|
||||||
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
string? status,
|
||||||
|
string? severity,
|
||||||
|
string? query,
|
||||||
|
bool? reviewedOnly,
|
||||||
|
AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var normalizedLimit = Math.Clamp(limit ?? 25, 1, 100);
|
||||||
|
var normalizedOffset = Math.Max(offset ?? 0, 0);
|
||||||
|
var normalizedStatus = NormalizeRiskFlagStatus(status, allowAll: true);
|
||||||
|
if (normalizedStatus is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Risk flag status must be open, resolved, dismissed or all." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var riskQuery = db.RiskFlags.AsNoTracking();
|
||||||
|
|
||||||
|
if (reviewedOnly == true)
|
||||||
|
{
|
||||||
|
riskQuery = riskQuery.Where(item => item.Status != "open");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var normalizedSeverity = severity.Trim().ToLowerInvariant();
|
||||||
|
if (normalizedSeverity is not ("low" or "medium" or "high"))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Risk flag severity must be low, medium, high or all." });
|
||||||
|
}
|
||||||
|
|
||||||
|
riskQuery = riskQuery.Where(item => item.Severity == normalizedSeverity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedStatus != "all")
|
||||||
|
{
|
||||||
|
riskQuery = riskQuery.Where(item => item.Status == normalizedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
var pattern = $"%{query.Trim()}%";
|
||||||
|
riskQuery = riskQuery.Where(item =>
|
||||||
|
EF.Functions.ILike(item.Source, pattern) ||
|
||||||
|
EF.Functions.ILike(item.Type, pattern) ||
|
||||||
|
EF.Functions.ILike(item.Severity, pattern) ||
|
||||||
|
EF.Functions.ILike(item.Status, pattern) ||
|
||||||
|
EF.Functions.ILike(item.Summary, pattern) ||
|
||||||
|
(item.TwitchUserId != null && EF.Functions.ILike(item.TwitchUserId, pattern)) ||
|
||||||
|
EF.Functions.ILike(item.CreatedFromIp, pattern) ||
|
||||||
|
(item.ReviewNote != null && EF.Functions.ILike(item.ReviewNote, pattern)) ||
|
||||||
|
EF.Functions.ILike(item.MetadataJson, pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await riskQuery.CountAsync();
|
||||||
|
var severityCounts = await riskQuery
|
||||||
|
.GroupBy(item => item.Severity)
|
||||||
|
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
|
||||||
|
.ToArrayAsync();
|
||||||
|
var statusCounts = await riskQuery
|
||||||
|
.GroupBy(item => item.Status)
|
||||||
|
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var flags = await riskQuery
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Skip(normalizedOffset)
|
||||||
|
.Take(normalizedLimit)
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var items = flags.Select(AdminRiskFlagMappings.ToDto).ToArray();
|
||||||
|
|
||||||
|
return Results.Ok(new AdminRiskFlagsResponse(
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
items.Length,
|
||||||
|
normalizedOffset,
|
||||||
|
normalizedLimit,
|
||||||
|
normalizedOffset + items.Length < totalCount,
|
||||||
|
severityCounts,
|
||||||
|
statusCounts));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ResolveRiskFlag(
|
||||||
|
HttpContext context,
|
||||||
|
int riskFlagId,
|
||||||
|
ResolveRiskFlagRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
|
||||||
|
if (riskFlag is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
|
||||||
|
if (normalizedStatus is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Risk flag status must be open, resolved or dismissed." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousStatus = riskFlag.Status;
|
||||||
|
var previousReviewNote = riskFlag.ReviewNote;
|
||||||
|
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
|
||||||
|
|
||||||
|
riskFlag.Status = normalizedStatus;
|
||||||
|
if (normalizedStatus == "open")
|
||||||
|
{
|
||||||
|
riskFlag.ReviewedAt = null;
|
||||||
|
riskFlag.ReviewedByTwitchId = null;
|
||||||
|
riskFlag.ReviewNote = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
riskFlag.ReviewNote = normalizedReviewNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"risk.resolve",
|
||||||
|
"risk-flag",
|
||||||
|
riskFlag.Id.ToString(),
|
||||||
|
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
riskFlag.Type,
|
||||||
|
riskFlag.Source,
|
||||||
|
changes = BuildRiskResolutionChanges(previousStatus, riskFlag.Status, previousReviewNote, riskFlag.ReviewNote),
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> BulkResolveRiskFlags(
|
||||||
|
HttpContext context,
|
||||||
|
BulkResolveRiskFlagsRequest? request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
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 normalizedIds = request.RiskFlagIds
|
||||||
|
.Distinct()
|
||||||
|
.Take(100)
|
||||||
|
.ToArray();
|
||||||
|
if (normalizedIds.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte waehle mindestens einen Risikohinweis aus." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
|
||||||
|
if (normalizedStatus is null || normalizedStatus == "open")
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bulk-Verarbeitung unterstuetzt erledigt oder verworfen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedReviewNote))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht eine Review-Notiz." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var riskFlags = await db.RiskFlags
|
||||||
|
.Where(item => normalizedIds.Contains(item.Id))
|
||||||
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
if (riskFlags.Length != normalizedIds.Length)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Mindestens ein Risikohinweis wurde nicht gefunden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskFlags.Any(item => item.Severity != "low"))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Low-Severity-Hinweise erlaubt." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskFlags.Any(item => item.Status != "open"))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Hinweise erlaubt." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var riskFlag in riskFlags)
|
||||||
|
{
|
||||||
|
riskFlag.Status = normalizedStatus;
|
||||||
|
riskFlag.ReviewNote = normalizedReviewNote;
|
||||||
|
riskFlag.ReviewedAt = reviewedAt;
|
||||||
|
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"risk.bulk-resolve",
|
||||||
|
"risk-flag",
|
||||||
|
string.Join(",", normalizedIds),
|
||||||
|
$"{normalizedIds.Length} Low-Risk Flags wurden als {normalizedStatus} markiert.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
status = normalizedStatus,
|
||||||
|
count = normalizedIds.Length,
|
||||||
|
riskFlagIds = normalizedIds,
|
||||||
|
changes = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
field = "status",
|
||||||
|
label = "Status",
|
||||||
|
from = "open",
|
||||||
|
to = normalizedStatus,
|
||||||
|
sensitive = false,
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
field = "reviewNote",
|
||||||
|
label = "Review-Notiz",
|
||||||
|
from = "keine Notiz",
|
||||||
|
to = "Notiz vorhanden",
|
||||||
|
sensitive = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, count = normalizedIds.Length, status = normalizedStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetRiskRules(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new AdminRiskRulesResponse(RiskRuleSettings.Read(settings).Select(ToRiskRuleDto).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateRiskRules(
|
||||||
|
HttpContext context,
|
||||||
|
UpdateRiskRulesRequest 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 = RiskRuleSettings.Read(settings);
|
||||||
|
var mergedRules = RiskRuleSettings.Defaults
|
||||||
|
.Select(defaultRule =>
|
||||||
|
{
|
||||||
|
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
|
||||||
|
return requestRule is null
|
||||||
|
? defaultRule
|
||||||
|
: new RiskRuleSetting(
|
||||||
|
defaultRule.Key,
|
||||||
|
defaultRule.Label,
|
||||||
|
requestRule.Enabled,
|
||||||
|
requestRule.Threshold,
|
||||||
|
requestRule.WindowMinutes,
|
||||||
|
requestRule.Severity,
|
||||||
|
defaultRule.Description);
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
settings.RiskRulesJson = RiskRuleSettings.Serialize(mergedRules);
|
||||||
|
var after = RiskRuleSettings.Read(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.Threshold}/{previous.WindowMinutes}/{previous.Severity}",
|
||||||
|
to = $"{rule.Enabled}/{rule.Threshold}/{rule.WindowMinutes}/{rule.Severity}",
|
||||||
|
sensitive = false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.Where(change => change.from != change.to)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"risk-rules.update",
|
||||||
|
"site-settings",
|
||||||
|
settings.Id.ToString(),
|
||||||
|
"Risk-Regeln wurden aktualisiert.",
|
||||||
|
new { changes },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new AdminRiskRulesResponse(after.Select(ToRiskRuleDto).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminRiskRuleDto ToRiskRuleDto(RiskRuleSetting rule) =>
|
||||||
|
new(rule.Key, rule.Label, rule.Enabled, rule.Threshold, rule.WindowMinutes, rule.Severity, rule.Description);
|
||||||
|
|
||||||
|
private static string? NormalizeReviewNote(string? reviewNote)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reviewNote))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedReviewNote = reviewNote.Trim();
|
||||||
|
return trimmedReviewNote.Length <= 500 ? trimmedReviewNote : trimmedReviewNote[..500];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object[] BuildRiskResolutionChanges(
|
||||||
|
string previousStatus,
|
||||||
|
string currentStatus,
|
||||||
|
string? previousReviewNote,
|
||||||
|
string? currentReviewNote)
|
||||||
|
{
|
||||||
|
var changes = new List<object>
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
field = "status",
|
||||||
|
label = "Status",
|
||||||
|
from = previousStatus,
|
||||||
|
to = currentStatus,
|
||||||
|
sensitive = false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.Equals(previousReviewNote, currentReviewNote, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
changes.Add(new
|
||||||
|
{
|
||||||
|
field = "reviewNote",
|
||||||
|
label = "Review-Notiz",
|
||||||
|
from = string.IsNullOrWhiteSpace(previousReviewNote) ? "keine Notiz" : "Notiz vorhanden",
|
||||||
|
to = string.IsNullOrWhiteSpace(currentReviewNote) ? "keine Notiz" : "Notiz vorhanden",
|
||||||
|
sensitive = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeRiskFlagStatus(string? status, bool allowAll)
|
||||||
|
{
|
||||||
|
var normalizedStatus = string.IsNullOrWhiteSpace(status) ? "open" : status.Trim().ToLowerInvariant();
|
||||||
|
return normalizedStatus switch
|
||||||
|
{
|
||||||
|
"open" or "resolved" or "dismissed" => normalizedStatus,
|
||||||
|
"all" when allowAll => normalizedStatus,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Domain;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminRiskFlagMappings
|
||||||
|
{
|
||||||
|
public static AdminRiskFlagDto ToDto(RiskFlag item) =>
|
||||||
|
new(
|
||||||
|
item.Id,
|
||||||
|
item.Source,
|
||||||
|
item.Type,
|
||||||
|
item.Severity,
|
||||||
|
item.Status,
|
||||||
|
item.Summary,
|
||||||
|
item.TwitchUserId,
|
||||||
|
item.CreatedFromIp,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.MetadataJson,
|
||||||
|
item.ReviewNote,
|
||||||
|
item.ReviewedByTwitchId,
|
||||||
|
item.ReviewedAt,
|
||||||
|
BuildEntityLinks(item));
|
||||||
|
|
||||||
|
public static AdminRiskEntityLinkDto[] BuildEntityLinks(RiskFlag item)
|
||||||
|
{
|
||||||
|
var links = ReadExplicitLinks(item.MetadataJson).ToList();
|
||||||
|
if (links.Count > 0)
|
||||||
|
{
|
||||||
|
return links.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = Uri.EscapeDataString(item.TwitchUserId ?? item.Summary);
|
||||||
|
return item.Source.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"clip" => [new AdminRiskEntityLinkDto("Clips öffnen", "clip", item.TwitchUserId ?? item.Id.ToString(), $"/admin/clips?query={query}")],
|
||||||
|
"nomination" => [new AdminRiskEntityLinkDto("Reviews öffnen", "nomination", item.TwitchUserId ?? item.Id.ToString(), $"/admin/reviews?query={query}")],
|
||||||
|
"vote" => [new AdminRiskEntityLinkDto("Voting-Analytics öffnen", "vote", item.TwitchUserId ?? item.Id.ToString(), $"/admin/analytics?query={query}")],
|
||||||
|
"login" => [new AdminRiskEntityLinkDto("Audit-Log öffnen", "session", item.TwitchUserId ?? item.Id.ToString(), $"/admin/users-logs?query={query}")],
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<AdminRiskEntityLinkDto> ReadExplicitLinks(string metadataJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(metadataJson))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument document;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
document = JsonDocument.Parse(metadataJson);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (document)
|
||||||
|
{
|
||||||
|
if (TryReadEntityLinks(document.RootElement, out var entityLinks))
|
||||||
|
{
|
||||||
|
foreach (var link in entityLinks)
|
||||||
|
{
|
||||||
|
yield return link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadEntityLinks(JsonElement root, out AdminRiskEntityLinkDto[] links)
|
||||||
|
{
|
||||||
|
links = [];
|
||||||
|
if (root.ValueKind != JsonValueKind.Object || !TryGetProperty(root, "entityLinks", out var entityLinks) || entityLinks.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
links = entityLinks.EnumerateArray()
|
||||||
|
.Where(item => item.ValueKind == JsonValueKind.Object)
|
||||||
|
.Select(item => new AdminRiskEntityLinkDto(
|
||||||
|
ReadString(item, "label"),
|
||||||
|
ReadString(item, "entityType"),
|
||||||
|
ReadString(item, "entityId"),
|
||||||
|
ReadString(item, "to")))
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.Label) && !string.IsNullOrWhiteSpace(item.To))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return links.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetProperty(JsonElement root, string propertyName, out JsonElement value)
|
||||||
|
{
|
||||||
|
foreach (var property in root.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = property.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadString(JsonElement root, string propertyName)
|
||||||
|
{
|
||||||
|
if (!TryGetProperty(root, propertyName, out var value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => value.GetString() ?? string.Empty,
|
||||||
|
JsonValueKind.Number => value.GetRawText(),
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
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 readonly string[] CandidateAcceptanceStatuses = ["open", "contacted", "accepted", "declined"];
|
||||||
|
private static readonly string[] CandidateClipEmbedStatuses = ["unchecked", "embeddable", "link_only", "blocked"];
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateCandidate(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
UpsertCandidateRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var validationError = ValidateCandidateRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId);
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedDisplayName = request.DisplayName.Trim();
|
||||||
|
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||||
|
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||||
|
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||||
|
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||||
|
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||||
|
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||||
|
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||||
|
|
||||||
|
if (normalizedClipUrl is null)
|
||||||
|
{
|
||||||
|
normalizedClipTitle = null;
|
||||||
|
normalizedClipPlatform = null;
|
||||||
|
normalizedClipEmbedStatus = "unchecked";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Candidates.AnyAsync(item =>
|
||||||
|
item.SeasonId == seasonId
|
||||||
|
&& item.CategoryId == request.CategoryId
|
||||||
|
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|
||||||
|
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||||
|
db,
|
||||||
|
seasonId,
|
||||||
|
request.CategoryId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
normalizedDisplayName,
|
||||||
|
normalizedChannelSlug,
|
||||||
|
normalizedAcceptanceStatus,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (workflowRuleBlock is not null)
|
||||||
|
{
|
||||||
|
return workflowRuleBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = new Candidate
|
||||||
|
{
|
||||||
|
SeasonId = seasonId,
|
||||||
|
CategoryId = request.CategoryId,
|
||||||
|
DisplayName = normalizedDisplayName,
|
||||||
|
ChannelSlug = normalizedChannelSlug,
|
||||||
|
Platform = request.Platform.Trim(),
|
||||||
|
AcceptanceStatus = normalizedAcceptanceStatus,
|
||||||
|
AcceptanceNote = normalizedAcceptanceNote,
|
||||||
|
ClipCompilationUrl = normalizedClipUrl,
|
||||||
|
ClipCompilationTitle = normalizedClipTitle,
|
||||||
|
ClipCompilationPlatform = normalizedClipPlatform,
|
||||||
|
ClipEmbedStatus = normalizedClipEmbedStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Candidates.Add(candidate);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"candidate.create",
|
||||||
|
"candidate",
|
||||||
|
request.DisplayName.Trim(),
|
||||||
|
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||||
|
new { seasonId, request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateCandidate(
|
||||||
|
HttpContext context,
|
||||||
|
int candidateId,
|
||||||
|
UpsertCandidateRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var validationError = ValidateCandidateRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == request.CategoryId && item.SeasonId == candidate.SeasonId);
|
||||||
|
if (targetCategory is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedDisplayName = request.DisplayName.Trim();
|
||||||
|
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||||
|
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||||
|
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||||
|
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||||
|
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||||
|
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||||
|
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||||
|
|
||||||
|
if (normalizedClipUrl is null)
|
||||||
|
{
|
||||||
|
normalizedClipTitle = null;
|
||||||
|
normalizedClipPlatform = null;
|
||||||
|
normalizedClipEmbedStatus = "unchecked";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Candidates.AnyAsync(item =>
|
||||||
|
item.SeasonId == candidate.SeasonId
|
||||||
|
&& item.CategoryId == request.CategoryId
|
||||||
|
&& item.Id != candidateId
|
||||||
|
&& (item.DisplayName.ToLower() == normalizedDisplayName.ToLower()
|
||||||
|
|| item.ChannelSlug.ToLower() == normalizedChannelSlug.ToLower())))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||||
|
db,
|
||||||
|
candidate.SeasonId,
|
||||||
|
request.CategoryId,
|
||||||
|
candidateId,
|
||||||
|
candidate.StreamerIdentityId,
|
||||||
|
normalizedDisplayName,
|
||||||
|
normalizedChannelSlug,
|
||||||
|
normalizedAcceptanceStatus,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (workflowRuleBlock is not null)
|
||||||
|
{
|
||||||
|
return workflowRuleBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.CategoryId = request.CategoryId;
|
||||||
|
candidate.DisplayName = normalizedDisplayName;
|
||||||
|
candidate.ChannelSlug = normalizedChannelSlug;
|
||||||
|
candidate.Platform = request.Platform.Trim();
|
||||||
|
candidate.AcceptanceStatus = normalizedAcceptanceStatus;
|
||||||
|
candidate.AcceptanceNote = normalizedAcceptanceNote;
|
||||||
|
candidate.ClipCompilationUrl = normalizedClipUrl;
|
||||||
|
candidate.ClipCompilationTitle = normalizedClipTitle;
|
||||||
|
candidate.ClipCompilationPlatform = normalizedClipPlatform;
|
||||||
|
candidate.ClipEmbedStatus = normalizedClipEmbedStatus;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"candidate.update",
|
||||||
|
"candidate",
|
||||||
|
candidate.Id.ToString(),
|
||||||
|
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||||
|
new { request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
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(
|
||||||
|
HttpContext context,
|
||||||
|
int candidateId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"candidate.delete",
|
||||||
|
"candidate",
|
||||||
|
candidate.Id.ToString(),
|
||||||
|
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
candidate.CategoryId,
|
||||||
|
candidate.Platform,
|
||||||
|
deletedNominations = linkedNominations.Count,
|
||||||
|
deletedClips = linkedClips.Count,
|
||||||
|
deletedVoteEntries = linkedVoteEntries.Count,
|
||||||
|
deletedResults = linkedResults.Count,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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> CreateCategory(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
UpsertCategoryRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var validationError = ValidateCategoryRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSlug = request.Slug.Trim();
|
||||||
|
if (await db.Categories.AnyAsync(item =>
|
||||||
|
item.SeasonId == seasonId
|
||||||
|
&& item.Slug.ToLower() == normalizedSlug.ToLower()))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = new Category
|
||||||
|
{
|
||||||
|
SeasonId = seasonId,
|
||||||
|
GroupName = request.GroupName.Trim(),
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Slug = normalizedSlug,
|
||||||
|
Description = request.Description.Trim(),
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
MaxNomineesPerUser = request.MaxNomineesPerUser,
|
||||||
|
ViewerRangeMin = request.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = request.ViewerRangeMax,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Categories.Add(category);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category.create",
|
||||||
|
"category",
|
||||||
|
request.Slug.Trim(),
|
||||||
|
$"Kategorie {request.Name.Trim()} wurde angelegt.",
|
||||||
|
new { seasonId, request.GroupName, request.SortOrder },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateCategory(
|
||||||
|
HttpContext context,
|
||||||
|
int categoryId,
|
||||||
|
UpsertCategoryRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var validationError = ValidateCategoryRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSlug = request.Slug.Trim();
|
||||||
|
if (await db.Categories.AnyAsync(item =>
|
||||||
|
item.SeasonId == category.SeasonId
|
||||||
|
&& item.Id != categoryId
|
||||||
|
&& item.Slug.ToLower() == normalizedSlug.ToLower()))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A category with this slug already exists in the selected season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
category.GroupName = request.GroupName.Trim();
|
||||||
|
category.Name = request.Name.Trim();
|
||||||
|
category.Slug = normalizedSlug;
|
||||||
|
category.Description = request.Description.Trim();
|
||||||
|
category.SortOrder = request.SortOrder;
|
||||||
|
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||||
|
category.ViewerRangeMin = request.ViewerRangeMin;
|
||||||
|
category.ViewerRangeMax = request.ViewerRangeMax;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category.update",
|
||||||
|
"category",
|
||||||
|
category.Id.ToString(),
|
||||||
|
$"Kategorie {request.Name.Trim()} wurde aktualisiert.",
|
||||||
|
new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteCategory(
|
||||||
|
HttpContext context,
|
||||||
|
int categoryId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync();
|
||||||
|
if (candidates.Length > 0)
|
||||||
|
{
|
||||||
|
db.Candidates.RemoveRange(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Categories.Remove(category);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category.delete",
|
||||||
|
"category",
|
||||||
|
category.Id.ToString(),
|
||||||
|
$"Kategorie {category.Name} wurde gelöscht.",
|
||||||
|
new { removedCandidates = candidates.Length },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, categoryId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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> CreateSeason(
|
||||||
|
HttpContext context,
|
||||||
|
CreateSeasonRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var validationError = ValidateSeasonRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Seasons.AnyAsync(item => item.Year == request.Year))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
|
var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings));
|
||||||
|
|
||||||
|
var season = new Season
|
||||||
|
{
|
||||||
|
Year = request.Year,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
IsDemo = false,
|
||||||
|
CurrentPhase = request.CurrentPhase.Trim(),
|
||||||
|
IsCurrent = request.IsCurrent,
|
||||||
|
IsCommunityOnly = request.IsCommunityOnly,
|
||||||
|
NominationStartsAt = request.NominationStartsAt,
|
||||||
|
NominationEndsAt = request.NominationEndsAt,
|
||||||
|
VotingStartsAt = request.VotingStartsAt,
|
||||||
|
VotingEndsAt = request.VotingEndsAt,
|
||||||
|
ReviewStartsAt = request.ReviewStartsAt,
|
||||||
|
ReviewEndsAt = request.ReviewEndsAt,
|
||||||
|
ShowDate = request.ShowDate,
|
||||||
|
ShowStartsAt = request.ShowStartsAt,
|
||||||
|
SubcategoryTemplatesJson = "[]",
|
||||||
|
WorkflowRulesJson = initialWorkflowRulesJson,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Seasons.Add(season);
|
||||||
|
var copiedCategoryCount = 0;
|
||||||
|
if (request.CopyStructureFromSeasonId is { } sourceSeasonId)
|
||||||
|
{
|
||||||
|
if (sourceSeasonId <= 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A valid source season is required for copying structure." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceSeasonExists = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||||
|
if (!sourceSeasonExists)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Source season for structure copy was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceCategories = await db.Categories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == sourceSeasonId)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
copiedCategoryCount = sourceCategories.Length;
|
||||||
|
var sourceSeason = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted);
|
||||||
|
season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson;
|
||||||
|
season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson)
|
||||||
|
? initialWorkflowRulesJson
|
||||||
|
: sourceSeason.WorkflowRulesJson;
|
||||||
|
foreach (var category in sourceCategories)
|
||||||
|
{
|
||||||
|
db.Categories.Add(new Category
|
||||||
|
{
|
||||||
|
Season = season,
|
||||||
|
GroupName = category.GroupName,
|
||||||
|
Name = category.Name,
|
||||||
|
Slug = category.Slug,
|
||||||
|
Description = category.Description,
|
||||||
|
SortOrder = category.SortOrder,
|
||||||
|
MaxNomineesPerUser = category.MaxNomineesPerUser,
|
||||||
|
ViewerRangeMin = category.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = category.ViewerRangeMax,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var readinessIssues = BuildNewSeasonReadinessIssues(
|
||||||
|
request.CurrentPhase,
|
||||||
|
request.IsCurrent,
|
||||||
|
copiedCategoryCount);
|
||||||
|
if (readinessIssues.Length > 0)
|
||||||
|
{
|
||||||
|
return CreateReadinessError(readinessIssues);
|
||||||
|
}
|
||||||
|
|
||||||
|
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent, null, context.RequestAborted);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"season.create",
|
||||||
|
"season",
|
||||||
|
request.Year.ToString(),
|
||||||
|
copiedCategoryCount > 0
|
||||||
|
? $"Season {request.Year} wurde angelegt und {copiedCategoryCount} Kategorien wurden kopiert."
|
||||||
|
: $"Season {request.Year} wurde angelegt.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.IsCurrent,
|
||||||
|
request.IsCommunityOnly,
|
||||||
|
request.CurrentPhase,
|
||||||
|
request.ShowDate,
|
||||||
|
request.ShowStartsAt,
|
||||||
|
request.CopyStructureFromSeasonId,
|
||||||
|
copiedCategoryCount,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, seasonId = season.Id, copiedCategoryCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> DeleteSeason(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season.IsCurrent)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das öffentlich aktive Award-Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var deletionSummary = await DeleteSeasonRelationsAsync(db, seasonId, context.RequestAborted);
|
||||||
|
|
||||||
|
db.Seasons.Remove(season);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"season.delete",
|
||||||
|
"season",
|
||||||
|
season.Id.ToString(),
|
||||||
|
$"Season {season.Year} wurde gelöscht.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
season.Year,
|
||||||
|
deletionSummary.DeletedVoteEntries,
|
||||||
|
deletionSummary.DeletedBallots,
|
||||||
|
deletionSummary.DeletedResults,
|
||||||
|
deletionSummary.DeletedNominations,
|
||||||
|
deletionSummary.DeletedClips,
|
||||||
|
deletionSummary.DeletedRiskFlags,
|
||||||
|
deletionSummary.DeletedCandidates,
|
||||||
|
deletionSummary.DeletedCategories,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
await transaction.CommitAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
deleted = true,
|
||||||
|
seasonId,
|
||||||
|
season.Year,
|
||||||
|
deletedVoteEntries = deletionSummary.DeletedVoteEntries,
|
||||||
|
deletedBallots = deletionSummary.DeletedBallots,
|
||||||
|
deletedResults = deletionSummary.DeletedResults,
|
||||||
|
deletedNominations = deletionSummary.DeletedNominations,
|
||||||
|
deletedClips = deletionSummary.DeletedClips,
|
||||||
|
deletedRiskFlags = deletionSummary.DeletedRiskFlags,
|
||||||
|
deletedCandidates = deletionSummary.DeletedCandidates,
|
||||||
|
deletedCategories = deletionSummary.DeletedCategories,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
|
{
|
||||||
|
private sealed record SeasonDeletionSummary(
|
||||||
|
int DeletedVoteEntries,
|
||||||
|
int DeletedBallots,
|
||||||
|
int DeletedResults,
|
||||||
|
int DeletedNominations,
|
||||||
|
int DeletedClips,
|
||||||
|
int DeletedRiskFlags,
|
||||||
|
int DeletedCandidates,
|
||||||
|
int DeletedCategories);
|
||||||
|
|
||||||
|
private static async Task<SeasonDeletionSummary> DeleteSeasonRelationsAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int seasonId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ballotIds = await db.VoteBallots
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
var deletedVoteEntries = ballotIds.Length == 0
|
||||||
|
? 0
|
||||||
|
: await db.VoteEntries
|
||||||
|
.Where(item => ballotIds.Contains(item.BallotId))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deletedBallots = await db.VoteBallots
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deletedResults = await db.Results
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deletedNominations = await db.Nominations
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deletedClips = await db.ClipSubmissions
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deletedRiskFlags = await db.RiskFlags
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deletedCandidates = await db.Candidates
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deletedCategories = await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new SeasonDeletionSummary(
|
||||||
|
deletedVoteEntries,
|
||||||
|
deletedBallots,
|
||||||
|
deletedResults,
|
||||||
|
deletedNominations,
|
||||||
|
deletedClips,
|
||||||
|
deletedRiskFlags,
|
||||||
|
deletedCandidates,
|
||||||
|
deletedCategories);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
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> GetSeasonDetail(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);
|
||||||
|
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||||
|
|
||||||
|
var candidateRows = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.DisplayName)
|
||||||
|
.Select(item => new AdminCandidateRow(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.DisplayName,
|
||||||
|
item.ChannelSlug,
|
||||||
|
item.Platform,
|
||||||
|
item.NominationTally,
|
||||||
|
item.AcceptanceStatus,
|
||||||
|
item.AcceptanceNote,
|
||||||
|
item.ClipCompilationUrl,
|
||||||
|
item.ClipCompilationTitle,
|
||||||
|
item.ClipCompilationPlatform,
|
||||||
|
item.ClipEmbedStatus))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var candidateCounts = candidateRows
|
||||||
|
.GroupBy(item => item.CategoryId)
|
||||||
|
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
|
||||||
|
|
||||||
|
var categoryRows = await db.Categories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.Select(category => new
|
||||||
|
{
|
||||||
|
category.Id,
|
||||||
|
category.GroupName,
|
||||||
|
category.Name,
|
||||||
|
category.Slug,
|
||||||
|
category.Description,
|
||||||
|
category.SortOrder,
|
||||||
|
category.MaxNomineesPerUser,
|
||||||
|
category.ViewerRangeMin,
|
||||||
|
category.ViewerRangeMax,
|
||||||
|
})
|
||||||
|
.ToArrayAsync();
|
||||||
|
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
|
||||||
|
season,
|
||||||
|
categoryRows.Select(category => new Backend.Domain.Category
|
||||||
|
{
|
||||||
|
GroupName = category.GroupName,
|
||||||
|
Name = category.Name,
|
||||||
|
Slug = category.Slug,
|
||||||
|
SortOrder = category.SortOrder,
|
||||||
|
ViewerRangeMin = category.ViewerRangeMin,
|
||||||
|
ViewerRangeMax = category.ViewerRangeMax,
|
||||||
|
}));
|
||||||
|
var categories = categoryRows
|
||||||
|
.Select(category => new AdminCategoryItemDto(
|
||||||
|
category.Id,
|
||||||
|
category.GroupName,
|
||||||
|
category.Name,
|
||||||
|
category.Slug,
|
||||||
|
category.Description,
|
||||||
|
category.SortOrder,
|
||||||
|
category.MaxNomineesPerUser,
|
||||||
|
category.ViewerRangeMin,
|
||||||
|
category.ViewerRangeMax,
|
||||||
|
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||||
|
|
||||||
|
var pendingNominationRows = await db.Nominations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Select(item => new AdminNominationRow(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||||
|
item.CategoryId != null ? item.Category!.Name : null,
|
||||||
|
item.SubmittedByTwitchId,
|
||||||
|
item.CandidateText ?? string.Empty,
|
||||||
|
item.StreamUrl,
|
||||||
|
item.ResolvedChannel,
|
||||||
|
item.ResolvedPlatform,
|
||||||
|
item.AvgViewers,
|
||||||
|
item.HoursStreamed,
|
||||||
|
item.HoursWatched,
|
||||||
|
item.PeakViewers,
|
||||||
|
item.FollowersGained,
|
||||||
|
item.SuggestedCategoryId,
|
||||||
|
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.TrackerStatus,
|
||||||
|
item.TrackerCheckedAt,
|
||||||
|
item.TrackingReviewStatus,
|
||||||
|
item.TrackingFlagsJson,
|
||||||
|
item.TrackingReviewNote,
|
||||||
|
item.TrackingReviewedByTwitchId,
|
||||||
|
item.TrackingReviewedAt,
|
||||||
|
item.Status,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.CandidateId,
|
||||||
|
item.CandidateId != null ? item.Candidate!.DisplayName : null,
|
||||||
|
item.ReviewNote,
|
||||||
|
item.ReviewedByTwitchId,
|
||||||
|
item.ReviewedAt))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var pendingNominations = pendingNominationRows
|
||||||
|
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
|
||||||
|
|
||||||
|
var reviewedNominationRows = await db.Nominations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||||
|
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||||
|
.Select(item => new AdminNominationRow(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||||
|
item.CategoryId != null ? item.Category!.Name : null,
|
||||||
|
item.SubmittedByTwitchId,
|
||||||
|
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||||
|
item.StreamUrl,
|
||||||
|
item.ResolvedChannel,
|
||||||
|
item.ResolvedPlatform,
|
||||||
|
item.AvgViewers,
|
||||||
|
item.HoursStreamed,
|
||||||
|
item.HoursWatched,
|
||||||
|
item.PeakViewers,
|
||||||
|
item.FollowersGained,
|
||||||
|
item.SuggestedCategoryId,
|
||||||
|
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.TrackerStatus,
|
||||||
|
item.TrackerCheckedAt,
|
||||||
|
item.TrackingReviewStatus,
|
||||||
|
item.TrackingFlagsJson,
|
||||||
|
item.TrackingReviewNote,
|
||||||
|
item.TrackingReviewedByTwitchId,
|
||||||
|
item.TrackingReviewedAt,
|
||||||
|
item.Status,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.CandidateId,
|
||||||
|
item.CandidateId != null ? item.Candidate!.DisplayName : null,
|
||||||
|
item.ReviewNote,
|
||||||
|
item.ReviewedByTwitchId,
|
||||||
|
item.ReviewedAt))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var reviewedNominations = reviewedNominationRows
|
||||||
|
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var resultItems = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderBy(item => item.Category.SortOrder)
|
||||||
|
.ThenBy(item => item.Category.Name)
|
||||||
|
.Select(item => new AdminAwardResultItemDto(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.Category.Name,
|
||||||
|
item.CandidateId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
|
item.Candidate.DisplayName,
|
||||||
|
item.Candidate.ChannelSlug,
|
||||||
|
item.Candidate.Platform))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var workflowRules = WorkflowRuleSettings.Read(season, settings);
|
||||||
|
var votingEntryRows = await db.VoteEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Category.SeasonId == seasonId)
|
||||||
|
.Select(item => new AdminVotingEntryRow(
|
||||||
|
item.BallotId,
|
||||||
|
item.CategoryId,
|
||||||
|
item.CandidateId))
|
||||||
|
.ToArrayAsync();
|
||||||
|
var candidates = BuildCandidateItems(
|
||||||
|
candidateRows,
|
||||||
|
pendingNominationRows,
|
||||||
|
reviewedNominationRows,
|
||||||
|
votingEntryRows);
|
||||||
|
var votingWorkspace = BuildVotingWorkspace(
|
||||||
|
categories,
|
||||||
|
candidates,
|
||||||
|
pendingNominations,
|
||||||
|
resultItems,
|
||||||
|
votingEntryRows,
|
||||||
|
workflowRules);
|
||||||
|
|
||||||
|
var clipSubmissions = await db.ClipSubmissions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Select(item => new AdminClipSubmissionItemDto(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.CandidateId,
|
||||||
|
item.SubmittedByTwitchId,
|
||||||
|
item.ClipUrl,
|
||||||
|
item.Title,
|
||||||
|
item.Creator,
|
||||||
|
item.Platform,
|
||||||
|
item.Status,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.ReviewNote,
|
||||||
|
item.ReviewedByTwitchId,
|
||||||
|
item.ReviewedAt))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new AdminSeasonDetailResponse(
|
||||||
|
season.Id,
|
||||||
|
season.Year,
|
||||||
|
season.Name,
|
||||||
|
season.IsDemo,
|
||||||
|
season.CurrentPhase,
|
||||||
|
season.IsCurrent,
|
||||||
|
season.IsCommunityOnly,
|
||||||
|
season.NominationStartsAt,
|
||||||
|
season.NominationEndsAt,
|
||||||
|
season.VotingStartsAt,
|
||||||
|
season.VotingEndsAt,
|
||||||
|
season.ReviewStartsAt,
|
||||||
|
season.ReviewEndsAt,
|
||||||
|
season.ShowDate,
|
||||||
|
season.ShowStartsAt,
|
||||||
|
season.WinnersPublishedAt,
|
||||||
|
season.WinnersPublishedByTwitchId,
|
||||||
|
subcategoryTemplates,
|
||||||
|
categories,
|
||||||
|
candidates,
|
||||||
|
pendingNominations,
|
||||||
|
pendingNominationGroups,
|
||||||
|
reviewedNominations,
|
||||||
|
settings?.TrackingReviewNotes ?? string.Empty,
|
||||||
|
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||||
|
resultItems,
|
||||||
|
votingWorkspace,
|
||||||
|
clipSubmissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AdminVotingEntryRow(
|
||||||
|
int BallotId,
|
||||||
|
int CategoryId,
|
||||||
|
int CandidateId);
|
||||||
|
|
||||||
|
private sealed record 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetSeasons(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var seasons = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(item => item.Year)
|
||||||
|
.Select(item => new AdminSeasonListItemDto(
|
||||||
|
item.Id,
|
||||||
|
item.Year,
|
||||||
|
item.Name,
|
||||||
|
item.CurrentPhase,
|
||||||
|
item.IsCurrent,
|
||||||
|
item.IsDemo,
|
||||||
|
item.Categories.Count,
|
||||||
|
item.WinnersPublishedAt,
|
||||||
|
item.WinnersPublishedByTwitchId))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(seasons);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
|
{
|
||||||
|
public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/seasons", GetSeasons)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
|
||||||
|
.WithName("GetAdminSeasons")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons", CreateSeason)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
|
||||||
|
.WithName("CreateAdminSeason")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
|
||||||
|
.WithName("GetAdminSeasonDetail")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/seasons/{seasonId:int}", UpdateSeason)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
|
||||||
|
.WithName("UpdateAdminSeason")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
|
||||||
|
.WithName("DeleteAdminSeason")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("CreateAdminCategory")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/categories/{categoryId:int}", UpdateCategory)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("UpdateAdminCategory")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapDelete("/categories/{categoryId:int}", DeleteCategory)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("DeleteAdminCategory")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("UpdateAdminSeasonSubcategoryTemplates")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("CreateAdminCategoryGroup")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("UpdateAdminCategoryGroup")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||||
|
.WithName("DeleteAdminCategoryGroup")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
|
.WithName("CreateAdminCandidate")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
|
.WithName("UpdateAdminCandidate")
|
||||||
|
.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)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
|
.WithName("DeleteAdminCandidate")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/results", SetResult)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("SetAdminResult")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapDelete("/results/{resultId:int}", DeleteResult)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("DeleteAdminResult")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/winners/publish", PublishWinners)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("PublishAdminSeasonWinners")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/seasons/{seasonId:int}/winners/unpublish", UnpublishWinners)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||||
|
.WithName("UnpublishAdminSeasonWinners")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||||
|
.WithName("GetAdminWorkflowRules")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
|
||||||
|
.WithName("UpdateAdminWorkflowRules")
|
||||||
|
.WithOpenApi();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
|
{
|
||||||
|
private const int MaxCategoryGroupNameLength = 80;
|
||||||
|
private const int MaxCategoryNameLength = 120;
|
||||||
|
private const int MaxCategorySlugLength = 120;
|
||||||
|
private const int MaxCategoryDescriptionLength = 600;
|
||||||
|
private const int MaxCandidateDisplayNameLength = 120;
|
||||||
|
private const int MaxCandidateChannelSlugLength = 120;
|
||||||
|
private const int MaxCandidatePlatformLength = 60;
|
||||||
|
private const int MaxCandidateAcceptanceNoteLength = 500;
|
||||||
|
private const int MaxCandidateClipUrlLength = 500;
|
||||||
|
private const int MaxCandidateClipTitleLength = 200;
|
||||||
|
private const int MaxCandidateClipPlatformLength = 40;
|
||||||
|
|
||||||
|
private sealed record CandidateRuleSnapshot(
|
||||||
|
int Id,
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string AcceptanceStatus);
|
||||||
|
|
||||||
|
private sealed record CandidateReadinessSnapshot(
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string AcceptanceStatus,
|
||||||
|
string? ClipCompilationUrl);
|
||||||
|
|
||||||
|
private sealed record WinnerReadinessSnapshot(
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string? ClipCompilationUrl);
|
||||||
|
|
||||||
|
private sealed record WinnerPublicationSnapshot(
|
||||||
|
int CategoryId,
|
||||||
|
int? StreamerIdentityId,
|
||||||
|
string DisplayName,
|
||||||
|
string ChannelSlug,
|
||||||
|
string? ClipCompilationUrl);
|
||||||
|
|
||||||
|
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||||
|
{
|
||||||
|
if (request.Year < 2020 || request.Year > 2100)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Please provide a valid award year." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Season name and current phase are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||||
|
request.NominationStartsAt,
|
||||||
|
request.NominationEndsAt,
|
||||||
|
request.VotingStartsAt,
|
||||||
|
request.VotingEndsAt,
|
||||||
|
request.ReviewStartsAt,
|
||||||
|
request.ReviewEndsAt,
|
||||||
|
request.ShowDate))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateSeasonRequest(UpdateSeasonRequest request)
|
||||||
|
{
|
||||||
|
if (request.Year < 2020 || request.Year > 2100)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Please provide a valid award year." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Season name and current phase are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||||
|
request.NominationStartsAt,
|
||||||
|
request.NominationEndsAt,
|
||||||
|
request.VotingStartsAt,
|
||||||
|
request.VotingEndsAt,
|
||||||
|
request.ReviewStartsAt,
|
||||||
|
request.ReviewEndsAt,
|
||||||
|
request.ShowDate))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The season schedule is not in chronological order." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKnownSeasonPhase(string? currentPhase)
|
||||||
|
{
|
||||||
|
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
|
return value.Contains("show")
|
||||||
|
|| value.Contains("abgeschlossen")
|
||||||
|
|| value.Contains("archiv")
|
||||||
|
|| value.Contains("complete")
|
||||||
|
|| value.Contains("ended")
|
||||||
|
|| value.Contains("aufbereit")
|
||||||
|
|| value.Contains("vorbereit")
|
||||||
|
|| value.Contains("pause")
|
||||||
|
|| value.Contains("review")
|
||||||
|
|| value.Contains("auswert")
|
||||||
|
|| value.Contains("vot")
|
||||||
|
|| value.Contains("nomin");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UnsetOtherCurrentSeasonsAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
bool shouldUnsetOthers,
|
||||||
|
int? seasonIdToKeep,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!shouldUnsetOthers)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeSeasons = await db.Seasons
|
||||||
|
.Where(item => item.IsCurrent && (!seasonIdToKeep.HasValue || item.Id != seasonIdToKeep.Value))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var activeSeason in activeSeasons)
|
||||||
|
{
|
||||||
|
activeSeason.IsCurrent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult CreateReadinessError(IEnumerable<string> issues)
|
||||||
|
{
|
||||||
|
var issueList = issues.ToArray();
|
||||||
|
return Results.BadRequest(new
|
||||||
|
{
|
||||||
|
message = $"Landingpage-Freigabe blockiert: {string.Join(" ", issueList)}",
|
||||||
|
issues = issueList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult CreateWinnerPublicationError(IEnumerable<string> issues)
|
||||||
|
{
|
||||||
|
var issueList = issues.ToArray();
|
||||||
|
return Results.BadRequest(new
|
||||||
|
{
|
||||||
|
message = $"Gewinner-Freigabe blockiert: {string.Join(" ", issueList)}",
|
||||||
|
issues = issueList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||||
|
var settings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||||
|
|
||||||
|
return WorkflowRuleSettings.Read(season, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult CreateWorkflowRuleError(string message) =>
|
||||||
|
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
|
||||||
|
|
||||||
|
private static async Task<IResult?> BuildCandidateWorkflowRuleBlockAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int seasonId,
|
||||||
|
int categoryId,
|
||||||
|
int? existingCandidateId,
|
||||||
|
int? streamerIdentityId,
|
||||||
|
string displayName,
|
||||||
|
string channelSlug,
|
||||||
|
string acceptanceStatus,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||||
|
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
|
||||||
|
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||||
|
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingCandidates = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.SeasonId == seasonId
|
||||||
|
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
|
||||||
|
&& item.AcceptanceStatus != "declined")
|
||||||
|
.Select(item => new CandidateRuleSnapshot(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.DisplayName,
|
||||||
|
item.ChannelSlug,
|
||||||
|
item.AcceptanceStatus))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
|
||||||
|
{
|
||||||
|
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
|
||||||
|
if (categoryCount >= finalistsRule.Limit)
|
||||||
|
{
|
||||||
|
return CreateWorkflowRuleError(
|
||||||
|
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||||
|
{
|
||||||
|
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||||
|
var appearanceCount = existingCandidates.Count(item =>
|
||||||
|
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||||
|
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||||
|
if (appearanceCount >= appearancesRule.Limit)
|
||||||
|
{
|
||||||
|
return CreateWorkflowRuleError(
|
||||||
|
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] BuildNewSeasonReadinessIssues(
|
||||||
|
string currentPhase,
|
||||||
|
bool isCurrent,
|
||||||
|
int copiedCategoryCount)
|
||||||
|
{
|
||||||
|
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||||
|
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||||
|
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||||
|
if (!isCurrent && !needsWinnerReadiness)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues = new List<string>();
|
||||||
|
if (copiedCategoryCount <= 0)
|
||||||
|
{
|
||||||
|
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsCandidateReadiness)
|
||||||
|
{
|
||||||
|
issues.Add("Kandidaten muessen vor dieser Phase fuer alle Kategorien gepflegt sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsWinnerReadiness)
|
||||||
|
{
|
||||||
|
issues.Add("Abgeschlossen ist erst moeglich, wenn jede Kategorie einen Gewinner hat.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string[]> BuildSeasonReadinessIssuesAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int seasonId,
|
||||||
|
string currentPhase,
|
||||||
|
bool isCurrent,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."];
|
||||||
|
}
|
||||||
|
|
||||||
|
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||||
|
var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent);
|
||||||
|
var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey);
|
||||||
|
if (!isCurrent && !needsWinnerReadiness)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||||
|
var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances);
|
||||||
|
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||||
|
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||||
|
|
||||||
|
var categoryIds = await db.Categories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
var issues = new List<string>();
|
||||||
|
if (categoryIds.Length == 0)
|
||||||
|
{
|
||||||
|
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||||
|
{
|
||||||
|
var candidateSnapshots = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => new CandidateReadinessSnapshot(
|
||||||
|
item.CategoryId,
|
||||||
|
item.StreamerIdentityId,
|
||||||
|
item.DisplayName,
|
||||||
|
item.ChannelSlug,
|
||||||
|
item.AcceptanceStatus,
|
||||||
|
item.ClipCompilationUrl))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var activeCandidates = candidateSnapshots
|
||||||
|
.Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
var categoriesWithCandidates = activeCandidates
|
||||||
|
.Select(item => item.CategoryId)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||||
|
if (emptyCategories > 0)
|
||||||
|
{
|
||||||
|
issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
|
||||||
|
{
|
||||||
|
var identityOverflow = activeCandidates
|
||||||
|
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Count = group.Count(),
|
||||||
|
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||||
|
})
|
||||||
|
.Where(item => item.Count > appearancesRule.Limit)
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (identityOverflow is not null)
|
||||||
|
{
|
||||||
|
issues.Add(
|
||||||
|
$"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsWinnerReadiness && categoryIds.Length > 0)
|
||||||
|
{
|
||||||
|
if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now))
|
||||||
|
{
|
||||||
|
issues.Add("Die Award Show liegt noch nicht in der Vergangenheit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoriesWithResults = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => item.CategoryId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults);
|
||||||
|
if (missingResults > 0)
|
||||||
|
{
|
||||||
|
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultSnapshots = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => new WinnerReadinessSnapshot(
|
||||||
|
item.CategoryId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
|
item.Candidate.DisplayName,
|
||||||
|
item.Candidate.ChannelSlug,
|
||||||
|
item.Candidate.ClipCompilationUrl))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
||||||
|
{
|
||||||
|
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
||||||
|
if (missingWinnerClipCount > 0)
|
||||||
|
{
|
||||||
|
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||||
|
{
|
||||||
|
var winnerOverflow = resultSnapshots
|
||||||
|
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Count = group.Count(),
|
||||||
|
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||||
|
})
|
||||||
|
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (winnerOverflow is not null)
|
||||||
|
{
|
||||||
|
issues.Add(
|
||||||
|
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string[]> BuildWinnerPublicationIssuesAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int seasonId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var categoryIds = await db.Categories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
var issues = new List<string>();
|
||||||
|
if (categoryIds.Length == 0)
|
||||||
|
{
|
||||||
|
issues.Add("Mindestens eine Kategorie ist erforderlich.");
|
||||||
|
return issues.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultSnapshots = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == seasonId)
|
||||||
|
.Select(item => new WinnerPublicationSnapshot(
|
||||||
|
item.CategoryId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
|
item.Candidate.DisplayName,
|
||||||
|
item.Candidate.ChannelSlug,
|
||||||
|
item.Candidate.ClipCompilationUrl))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
var categoriesWithResults = resultSnapshots
|
||||||
|
.Select(item => item.CategoryId)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults);
|
||||||
|
if (missingResults > 0)
|
||||||
|
{
|
||||||
|
issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var openReviewCount = await db.Nominations
|
||||||
|
.AsNoTracking()
|
||||||
|
.CountAsync(item => item.SeasonId == seasonId && item.Status == "pending", cancellationToken);
|
||||||
|
if (openReviewCount > 0)
|
||||||
|
{
|
||||||
|
issues.Add($"{openReviewCount} Nominierungs-Review(s) sind noch offen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken);
|
||||||
|
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule))
|
||||||
|
{
|
||||||
|
var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl));
|
||||||
|
if (missingWinnerClipCount > 0)
|
||||||
|
{
|
||||||
|
issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||||
|
{
|
||||||
|
var winnerOverflow = resultSnapshots
|
||||||
|
.GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug))
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Count = group.Count(),
|
||||||
|
DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt",
|
||||||
|
})
|
||||||
|
.Where(item => item.Count > winnerPlacementsRule.Limit)
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (winnerOverflow is not null)
|
||||||
|
{
|
||||||
|
issues.Add(
|
||||||
|
$"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug)
|
||||||
|
{
|
||||||
|
if (streamerIdentityId.HasValue)
|
||||||
|
{
|
||||||
|
return $"identity:{streamerIdentityId.Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent)
|
||||||
|
{
|
||||||
|
return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal))
|
||||||
|
|| string.Equals(phaseKey, "completed", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiresWinnerReadiness(string phaseKey)
|
||||||
|
{
|
||||||
|
return string.Equals(phaseKey, "completed", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateCategoryRequest(UpsertCategoryRequest request)
|
||||||
|
{
|
||||||
|
var groupName = request.GroupName.Trim();
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
var slug = request.Slug.Trim();
|
||||||
|
var description = request.Description.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > MaxCategoryGroupNameLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Category group name is required and must stay below {MaxCategoryGroupNameLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || name.Length > MaxCategoryNameLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Category name is required and must stay below {MaxCategoryNameLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(slug) || slug.Length > MaxCategorySlugLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Category slug is required and must stay below {MaxCategorySlugLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_'))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Category slug contains unsupported characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.Length > MaxCategoryDescriptionLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Category description must stay below {MaxCategoryDescriptionLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SortOrder is < 0 or > 500)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Category sort order must be between 0 and 500." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.MaxNomineesPerUser is < 1 or > 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateCandidateRequest(UpsertCandidateRequest request)
|
||||||
|
{
|
||||||
|
var displayName = request.DisplayName.Trim();
|
||||||
|
var channelSlug = request.ChannelSlug.Trim();
|
||||||
|
var platform = request.Platform.Trim();
|
||||||
|
|
||||||
|
if (request.CategoryId <= 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A valid category is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > MaxCandidateDisplayNameLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxCandidateDisplayNameLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(channelSlug) || channelSlug.Length > MaxCandidateChannelSlugLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Channel slug is required and must stay below {MaxCandidateChannelSlugLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelSlug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_' or '.'))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Channel slug contains unsupported characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(platform) || platform.Length > MaxCandidatePlatformLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAllowedCandidateChoice(request.AcceptanceStatus, CandidateAcceptanceStatuses))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Acceptance status must be open, contacted, accepted, or declined." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAllowedCandidateChoice(request.ClipEmbedStatus, CandidateClipEmbedStatuses))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Clip embed status must be unchecked, embeddable, link_only, or blocked." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AcceptanceNote?.Trim().Length > MaxCandidateAcceptanceNoteLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Acceptance note must stay below {MaxCandidateAcceptanceNoteLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var clipUrl = request.ClipCompilationUrl?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(clipUrl))
|
||||||
|
{
|
||||||
|
if (clipUrl.Length > MaxCandidateClipUrlLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Compilation link must stay below {MaxCandidateClipUrlLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Compilation link must be a valid http(s) URL." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ClipCompilationTitle?.Trim().Length > MaxCandidateClipTitleLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Compilation title must stay below {MaxCandidateClipTitleLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ClipCompilationPlatform?.Trim().Length > MaxCandidateClipPlatformLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Compilation platform must stay below {MaxCandidateClipPlatformLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAllowedCandidateChoice(string? value, IReadOnlyCollection<string> allowedValues)
|
||||||
|
{
|
||||||
|
var normalized = value?.Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) || allowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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> SetResult(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
SetAwardResultRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var category = await db.Categories
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId);
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == request.CandidateId
|
||||||
|
&& item.SeasonId == seasonId
|
||||||
|
&& item.CategoryId == request.CategoryId);
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted);
|
||||||
|
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
|
||||||
|
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
|
||||||
|
{
|
||||||
|
return CreateWorkflowRuleError(
|
||||||
|
"Dieser Kandidat hat noch keinen gepflegten Clip-Link. Bitte zuerst die Clip-Compilation am Kandidaten hinterlegen oder die Workflow-Regel umstellen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
|
||||||
|
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
|
||||||
|
{
|
||||||
|
var candidateIdentityKey = WorkflowRuleSettings.CandidateIdentityKey(candidate);
|
||||||
|
var existingWinnerIdentities = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(item => item.Candidate)
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.CategoryId != request.CategoryId)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.CategoryId,
|
||||||
|
item.Candidate.StreamerIdentityId,
|
||||||
|
item.Candidate.DisplayName,
|
||||||
|
item.Candidate.ChannelSlug,
|
||||||
|
})
|
||||||
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
var existingWinnerCount = existingWinnerIdentities.Count(item =>
|
||||||
|
candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId
|
||||||
|
||
|
||||||
|
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (existingWinnerCount >= winnerPlacementsRule.Limit)
|
||||||
|
{
|
||||||
|
return CreateWorkflowRuleError(
|
||||||
|
$"Diese Person hat bereits {existingWinnerCount} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||||
|
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
|
||||||
|
item.SeasonId == seasonId
|
||||||
|
&& item.CategoryId == request.CategoryId);
|
||||||
|
|
||||||
|
if (existingResult is null)
|
||||||
|
{
|
||||||
|
existingResult = new AwardResult
|
||||||
|
{
|
||||||
|
SeasonId = seasonId,
|
||||||
|
CategoryId = request.CategoryId,
|
||||||
|
CandidateId = request.CandidateId,
|
||||||
|
CategoryName = category.Name,
|
||||||
|
};
|
||||||
|
db.Results.Add(existingResult);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existingResult.CandidateId = request.CandidateId;
|
||||||
|
existingResult.CategoryName = category.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasPublished = season?.WinnersPublishedAt is not null;
|
||||||
|
if (wasPublished && season is not null)
|
||||||
|
{
|
||||||
|
season.WinnersPublishedAt = null;
|
||||||
|
season.WinnersPublishedByTwitchId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"result.set",
|
||||||
|
"result",
|
||||||
|
$"{seasonId}:{request.CategoryId}",
|
||||||
|
$"Gewinner für {category.Name} wurde gesetzt.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
seasonId,
|
||||||
|
categoryId = request.CategoryId,
|
||||||
|
candidateId = request.CandidateId,
|
||||||
|
candidateName = candidate.DisplayName,
|
||||||
|
unpublishedWinners = wasPublished,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
saved = true,
|
||||||
|
resultId = existingResult.Id,
|
||||||
|
seasonId,
|
||||||
|
categoryId = request.CategoryId,
|
||||||
|
candidateId = request.CandidateId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteResult(
|
||||||
|
HttpContext context,
|
||||||
|
int resultId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var result = await db.Results
|
||||||
|
.Include(item => item.Category)
|
||||||
|
.Include(item => item.Candidate)
|
||||||
|
.Include(item => item.Season)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == resultId);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasPublished = result.Season.WinnersPublishedAt is not null;
|
||||||
|
if (wasPublished)
|
||||||
|
{
|
||||||
|
result.Season.WinnersPublishedAt = null;
|
||||||
|
result.Season.WinnersPublishedByTwitchId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Results.Remove(result);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"result.delete",
|
||||||
|
"result",
|
||||||
|
result.Id.ToString(),
|
||||||
|
$"Gewinner für {result.Category.Name} wurde entfernt.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
result.SeasonId,
|
||||||
|
result.CategoryId,
|
||||||
|
result.CandidateId,
|
||||||
|
candidateName = result.Candidate.DisplayName,
|
||||||
|
unpublishedWinners = wasPublished,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { deleted = true, resultId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PublishWinners(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues = await BuildWinnerPublicationIssuesAsync(db, seasonId, context.RequestAborted);
|
||||||
|
if (issues.Length > 0)
|
||||||
|
{
|
||||||
|
return CreateWinnerPublicationError(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
season.WinnersPublishedAt = DateTimeOffset.UtcNow;
|
||||||
|
season.WinnersPublishedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"winners.publish",
|
||||||
|
"season",
|
||||||
|
season.Id.ToString(),
|
||||||
|
$"Gewinner für {season.Year} wurden veröffentlicht.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
seasonId,
|
||||||
|
season.Year,
|
||||||
|
season.WinnersPublishedAt,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
saved = true,
|
||||||
|
seasonId,
|
||||||
|
winnersPublishedAt = season.WinnersPublishedAt,
|
||||||
|
winnersPublishedByTwitchId = season.WinnersPublishedByTwitchId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UnpublishWinners(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousPublishedAt = season.WinnersPublishedAt;
|
||||||
|
season.WinnersPublishedAt = null;
|
||||||
|
season.WinnersPublishedByTwitchId = null;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"winners.unpublish",
|
||||||
|
"season",
|
||||||
|
season.Id.ToString(),
|
||||||
|
$"Gewinner für {season.Year} wurden von der Landingpage zurückgenommen.",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
seasonId,
|
||||||
|
season.Year,
|
||||||
|
previousPublishedAt,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
saved = true,
|
||||||
|
seasonId,
|
||||||
|
winnersPublishedAt = season.WinnersPublishedAt,
|
||||||
|
winnersPublishedByTwitchId = season.WinnersPublishedByTwitchId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AdminSeasonManagementEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> UpdateSeason(
|
||||||
|
HttpContext context,
|
||||||
|
int seasonId,
|
||||||
|
UpdateSeasonRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationError = ValidateSeasonRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Seasons.AnyAsync(item => item.Id != seasonId && item.Year == request.Year))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"A season for {request.Year} already exists." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasCurrent = season.IsCurrent;
|
||||||
|
var previousPhase = season.CurrentPhase;
|
||||||
|
var previousPhaseKey = SeasonMappings.NormalizePhaseKey(previousPhase);
|
||||||
|
var requestedPhaseKey = SeasonMappings.NormalizePhaseKey(request.CurrentPhase);
|
||||||
|
var shouldValidateReadiness = request.IsCurrent
|
||||||
|
|| (string.Equals(requestedPhaseKey, "completed", StringComparison.Ordinal)
|
||||||
|
&& !string.Equals(previousPhaseKey, "completed", StringComparison.Ordinal));
|
||||||
|
if (shouldValidateReadiness)
|
||||||
|
{
|
||||||
|
var readinessIssues = await BuildSeasonReadinessIssuesAsync(
|
||||||
|
db,
|
||||||
|
seasonId,
|
||||||
|
request.CurrentPhase,
|
||||||
|
request.IsCurrent,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (readinessIssues.Length > 0)
|
||||||
|
{
|
||||||
|
return CreateReadinessError(readinessIssues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
season.Year = request.Year;
|
||||||
|
season.Name = request.Name.Trim();
|
||||||
|
season.CurrentPhase = request.CurrentPhase.Trim();
|
||||||
|
season.IsCommunityOnly = request.IsCommunityOnly;
|
||||||
|
season.NominationStartsAt = request.NominationStartsAt;
|
||||||
|
season.NominationEndsAt = request.NominationEndsAt;
|
||||||
|
season.VotingStartsAt = request.VotingStartsAt;
|
||||||
|
season.VotingEndsAt = request.VotingEndsAt;
|
||||||
|
season.ReviewStartsAt = request.ReviewStartsAt;
|
||||||
|
season.ReviewEndsAt = request.ReviewEndsAt;
|
||||||
|
season.ShowDate = request.ShowDate;
|
||||||
|
season.ShowStartsAt = request.ShowStartsAt;
|
||||||
|
|
||||||
|
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent, seasonId, context.RequestAborted);
|
||||||
|
|
||||||
|
season.IsCurrent = request.IsCurrent;
|
||||||
|
var actionType = "season.update";
|
||||||
|
var summary = $"Season {season.Year} wurde aktualisiert.";
|
||||||
|
if (!string.Equals(previousPhase.Trim(), season.CurrentPhase, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
actionType = "season.phase.update";
|
||||||
|
summary = $"Phase fuer Season {season.Year} wurde auf {season.CurrentPhase} gesetzt.";
|
||||||
|
}
|
||||||
|
else if (wasCurrent != request.IsCurrent)
|
||||||
|
{
|
||||||
|
actionType = "season.public.update";
|
||||||
|
summary = request.IsCurrent
|
||||||
|
? $"Season {season.Year} wurde als Public-Kontext aktiviert."
|
||||||
|
: $"Season {season.Year} wurde aus dem Public-Kontext entfernt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
actionType,
|
||||||
|
"season",
|
||||||
|
season.Id.ToString(),
|
||||||
|
summary,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.Year,
|
||||||
|
request.Name,
|
||||||
|
previousPhase,
|
||||||
|
request.CurrentPhase,
|
||||||
|
wasCurrent,
|
||||||
|
request.IsCurrent,
|
||||||
|
request.IsCommunityOnly,
|
||||||
|
request.ShowDate,
|
||||||
|
request.ShowStartsAt,
|
||||||
|
},
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, seasonId = season.Id });
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,630 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
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 AdminTeamEndpoints
|
||||||
|
{
|
||||||
|
private const int MinPasswordLength = 10;
|
||||||
|
private static readonly TimeSpan OnlineActivityWindow = TimeSpan.FromMinutes(5);
|
||||||
|
private const string PersonalOwnerLogin = "jayuhime";
|
||||||
|
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
|
||||||
|
|
||||||
|
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
|
||||||
|
[
|
||||||
|
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "Betrieb", "/admin/dashboard", true),
|
||||||
|
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "Betrieb", "/admin/nominations", false),
|
||||||
|
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "Awards", "/admin/years", false),
|
||||||
|
new(AdminPermissionCatalog.Categories, "Kategorien", "Hauptkategorien, Unterkategorien und Limits verwalten.", "Awards", "/admin/categories", false),
|
||||||
|
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis, Clips und Annahmestatus pflegen.", "Awards", "/admin/candidates", false),
|
||||||
|
new(AdminPermissionCatalog.Clips, "Clips", "Optionale Clip-Einreichungen prüfen.", "Awards", "/admin/clips", false),
|
||||||
|
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Footer, Showacts und öffentliche Inhalte pflegen.", "Landingpage", "/admin/content", false),
|
||||||
|
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "Kontrolle", "/admin/risk", true),
|
||||||
|
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "Kontrolle", "/admin/users-logs", true),
|
||||||
|
new(AdminPermissionCatalog.Analytics, "Analytics", "Jahresmetriken und Überblick lesen.", "Auswertung", "/admin/analytics", true),
|
||||||
|
new(AdminPermissionCatalog.Voting, "Voting", "Stimmenlage und Gewinner-Vorbereitung sehen.", "Auswertung", "/admin/voting", true),
|
||||||
|
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "Auswertung", "/admin/winners", false),
|
||||||
|
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang, Wartung und Workflow-Steuerung sehen.", "Einstellungen", "/admin/settings", true),
|
||||||
|
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "Einstellungen", "/admin/team", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly AdminTeamRoleDto[] DefaultRoles =
|
||||||
|
[
|
||||||
|
new(AdminRoles.Owner, "Owner", "Jayuhime: vollständige Kontrolle inklusive Passwortreset und Betriebseinstellungen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Owner)),
|
||||||
|
new(AdminRoles.Creator, "Creator", "Persönliche Jayuhime-Rolle mit denselben Rechten wie Owner.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Creator)),
|
||||||
|
new(AdminRoles.Admin, "Admins", "Operatives Kernteam mit Schreibrechten in allen Award-Bereichen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Admin)),
|
||||||
|
new(AdminRoles.Member, "Mitglied", "Internes Team für Pflege, Nominierungen und Clip-Arbeit.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Member)),
|
||||||
|
new(AdminRoles.Reviewer, "Reviewer", "Review-Fokus für Nominierungen, Clips und Risiko-Hinweise.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Reviewer)),
|
||||||
|
new(AdminRoles.OrganizationTeam, "Organisation Team", "Externe Personen mit Read-only-Sicht auf ausgewählte Bereiche.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.OrganizationTeam)),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static RouteGroupBuilder MapAdminTeamEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/team", GetTeam)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("GetAdminTeam")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/team/members", CreateMember)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("CreateAdminTeamMember")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/team/members/{memberId:int}", UpdateMember)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("UpdateAdminTeamMember")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/team/members/{memberId:int}/reset-password", ResetPassword)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("ResetAdminTeamMemberPassword")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapDelete("/team/members/{memberId:int}", DeleteMember)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("DeleteAdminTeamMember")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/team/roles", UpdateRoles)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("UpdateAdminTeamRoles")
|
||||||
|
.WithOpenApi();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetTeam(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var members = await db.TeamMembers
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(item => item.Role == AdminRoles.Owner)
|
||||||
|
.ThenBy(item => item.Role)
|
||||||
|
.ThenBy(item => item.DisplayName)
|
||||||
|
.ToArrayAsync();
|
||||||
|
var sessionKeys = members
|
||||||
|
.SelectMany(MemberSessionKeys)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var sessions = sessionKeys.Length == 0
|
||||||
|
? []
|
||||||
|
: await db.UserSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => sessionKeys.Contains(item.TwitchUserId))
|
||||||
|
.ToArrayAsync();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
return Results.Ok(new AdminTeamResponse(
|
||||||
|
members.Select(member => ToMemberDto(member, sessions, now)),
|
||||||
|
await BuildRoleDtosAsync(db),
|
||||||
|
PermissionCatalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateMember(
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
CreateTeamMemberRequest request,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
|
||||||
|
var normalizedLogin = NormalizeLogin(request.Login);
|
||||||
|
var normalizedRole = AdminRoles.Normalize(request.Role);
|
||||||
|
var displayName = NormalizeDisplayName(request.DisplayName);
|
||||||
|
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedRole == AdminRoles.Creator)
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Creator-Konten werden fest vorkonfiguriert und können nicht manuell angelegt werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AdminRoles.IsPrivilegedFullControlRole(normalizedRole) && !AdminRoles.CanResetTeamPasswords(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator angelegt werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, null, normalizedLogin, normalizedRole, context.RequestAborted);
|
||||||
|
if (privilegedValidationError is not null)
|
||||||
|
{
|
||||||
|
return privilegedValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.TeamMembers.AnyAsync(item => item.Login == normalizedLogin, context.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var generatedPassword = GeneratePassword();
|
||||||
|
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
|
||||||
|
var member = new TeamMember
|
||||||
|
{
|
||||||
|
Login = normalizedLogin,
|
||||||
|
DisplayName = displayName,
|
||||||
|
Role = normalizedRole,
|
||||||
|
PasswordHash = credentials.Hash,
|
||||||
|
PasswordSalt = credentials.Salt,
|
||||||
|
MustChangePassword = true,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedByTwitchId = session.TwitchUserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.TeamMembers.Add(member);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.create",
|
||||||
|
"team-member",
|
||||||
|
normalizedLogin,
|
||||||
|
$"Team-Login {displayName} wurde angelegt.",
|
||||||
|
new { member.Login, member.DisplayName, member.Role },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateMember(
|
||||||
|
int memberId,
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
UpdateTeamMemberRequest request,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedLogin = NormalizeLogin(request.Login);
|
||||||
|
var normalizedRole = AdminRoles.Normalize(request.Role);
|
||||||
|
var displayName = NormalizeDisplayName(request.DisplayName);
|
||||||
|
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginChanged = !string.Equals(member.Login, normalizedLogin, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (loginChanged && !AdminRoles.CanDeleteTeamMembers(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Logins können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginChanged && await db.TeamMembers.AnyAsync(item => item.Id != member.Id && item.Login == normalizedLogin, context.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedRole == AdminRoles.Creator && member.Role != AdminRoles.Creator)
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Creator-Konten werden fest vorkonfiguriert und können nicht manuell zugewiesen werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((AdminRoles.IsPrivilegedFullControlRole(member.Role) || AdminRoles.IsPrivilegedFullControlRole(normalizedRole)) && !AdminRoles.CanResetTeamPasswords(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator bearbeitet werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousLogin = member.Login;
|
||||||
|
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, member.Id, normalizedLogin, normalizedRole, context.RequestAborted);
|
||||||
|
if (privilegedValidationError is not null)
|
||||||
|
{
|
||||||
|
return privilegedValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
member.Login = normalizedLogin;
|
||||||
|
member.DisplayName = displayName;
|
||||||
|
member.Role = normalizedRole;
|
||||||
|
member.IsActive = request.IsActive;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.update",
|
||||||
|
"team-member",
|
||||||
|
member.Id.ToString(),
|
||||||
|
$"Team-Login {member.DisplayName} wurde aktualisiert.",
|
||||||
|
new { previousLogin, member.Login, member.DisplayName, member.Role, member.IsActive },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
if (!member.IsActive)
|
||||||
|
{
|
||||||
|
DeactivateMemberSessions(db, member, previousLogin);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SyncMemberSessionRolesAsync(db, member, previousLogin, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, member = ToMemberDto(member) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ResetPassword(
|
||||||
|
int memberId,
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
if (!AdminRoles.CanResetTeamPasswords(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Passwort-Reset ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var generatedPassword = GeneratePassword();
|
||||||
|
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
|
||||||
|
member.PasswordHash = credentials.Hash;
|
||||||
|
member.PasswordSalt = credentials.Salt;
|
||||||
|
member.MustChangePassword = true;
|
||||||
|
member.PasswordResetAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.password-reset",
|
||||||
|
"team-member",
|
||||||
|
member.Id.ToString(),
|
||||||
|
$"Passwort für {member.DisplayName} wurde zurückgesetzt.",
|
||||||
|
new { member.Login, member.Role },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteMember(
|
||||||
|
int memberId,
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
if (!AdminRoles.CanDeleteTeamMembers(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Mitglieder löschen ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AdminRoles.IsPrivilegedFullControlRole(member.Role) && !CanDeletePrivilegedMember(session, member))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Creator darf Owner-Konten löschen. Creator-Konten können nur vom eigenen Creator-Account gelöscht werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeactivateMemberSessions(db, member);
|
||||||
|
db.TeamMembers.Remove(member);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.delete",
|
||||||
|
"team-member",
|
||||||
|
member.Id.ToString(),
|
||||||
|
$"Team-Login {member.DisplayName} wurde gelöscht.",
|
||||||
|
new { member.Login, member.DisplayName, member.Role },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new DeleteTeamMemberResponse(true, memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateRoles(
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
UpdateTeamRolesRequest request,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
if (!AdminRoles.IsPrivilegedFullControlRole(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Rollenberechtigungen können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
|
||||||
|
var roleChanges = new List<object>();
|
||||||
|
|
||||||
|
foreach (var roleUpdate in request.Roles ?? [])
|
||||||
|
{
|
||||||
|
var role = AdminRoles.Normalize(roleUpdate.Key);
|
||||||
|
if (!allowedRoles.Contains(role))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Unbekannte Rolle: {roleUpdate.Key}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var permissionKeys = (roleUpdate.PermissionKeys ?? [])
|
||||||
|
.Select(item => item.Trim())
|
||||||
|
.Where(item => allowedPermissions.Contains(item))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
permissionKeys = AdminPermissionCatalog.NormalizePermissionKeys(role, permissionKeys);
|
||||||
|
|
||||||
|
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var beforePermissions = existingRows.TryGetValue(role, out var existingRow)
|
||||||
|
? AdminPermissionCatalog.NormalizePermissionKeys(role, ReadPermissionKeys(existingRow.PermissionsJson, AdminPermissionCatalog.DefaultPermissionKeys(role)))
|
||||||
|
: AdminPermissionCatalog.NormalizePermissionKeys(role, AdminPermissionCatalog.DefaultPermissionKeys(role));
|
||||||
|
|
||||||
|
if (!existingRows.TryGetValue(role, out var row))
|
||||||
|
{
|
||||||
|
row = new TeamRolePermission { Role = role };
|
||||||
|
db.TeamRolePermissions.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
|
||||||
|
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
row.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
if (!beforePermissions.SequenceEqual(permissionKeys, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
roleChanges.Add(new
|
||||||
|
{
|
||||||
|
role,
|
||||||
|
before = beforePermissions,
|
||||||
|
after = permissionKeys,
|
||||||
|
added = permissionKeys.Except(beforePermissions, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||||
|
removed = beforePermissions.Except(permissionKeys, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.roles.update",
|
||||||
|
"team-role",
|
||||||
|
"matrix",
|
||||||
|
"Team-Rollenberechtigungen wurden gespeichert.",
|
||||||
|
new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, roles = await BuildRoleDtosAsync(db) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<AdminTeamRoleDto[]> BuildRoleDtosAsync(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var overrides = await db.TeamRolePermissions
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(item => item.Role, item => item.PermissionsJson);
|
||||||
|
|
||||||
|
return DefaultRoles
|
||||||
|
.Select(role =>
|
||||||
|
{
|
||||||
|
var permissions = overrides.TryGetValue(role.Key, out var json)
|
||||||
|
? ReadPermissionKeys(json, role.PermissionKeys)
|
||||||
|
: role.PermissionKeys;
|
||||||
|
|
||||||
|
permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions);
|
||||||
|
|
||||||
|
return role with { PermissionKeys = permissions };
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminTeamMemberDto ToMemberDto(
|
||||||
|
TeamMember member,
|
||||||
|
IReadOnlyCollection<UserSession>? sessions = null,
|
||||||
|
DateTimeOffset? now = null)
|
||||||
|
{
|
||||||
|
var memberSessions = sessions?
|
||||||
|
.Where(session => MemberSessionKeys(member).Contains(session.TwitchUserId, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.ToArray() ?? [];
|
||||||
|
var lastSeenAt = memberSessions
|
||||||
|
.Select(session => (DateTimeOffset?)session.LastSeenAt)
|
||||||
|
.Max() ?? member.LastLoginAt;
|
||||||
|
var isOnline = member.IsActive
|
||||||
|
&& memberSessions.Any(session =>
|
||||||
|
session.IsActive
|
||||||
|
&& session.LastSeenAt >= (now ?? DateTimeOffset.UtcNow).Subtract(OnlineActivityWindow));
|
||||||
|
|
||||||
|
return new(
|
||||||
|
member.Id,
|
||||||
|
member.Login,
|
||||||
|
member.DisplayName,
|
||||||
|
member.Role,
|
||||||
|
member.BoundTwitchUserId,
|
||||||
|
member.BoundTwitchDisplayName,
|
||||||
|
member.IsActive,
|
||||||
|
member.MustChangePassword,
|
||||||
|
member.CreatedAt,
|
||||||
|
member.UpdatedAt,
|
||||||
|
member.LastLoginAt,
|
||||||
|
lastSeenAt,
|
||||||
|
isOnline,
|
||||||
|
member.TwitchBoundAt,
|
||||||
|
member.PasswordResetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> MemberSessionKeys(TeamMember member)
|
||||||
|
{
|
||||||
|
yield return $"team:{member.Login}";
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId))
|
||||||
|
{
|
||||||
|
yield return member.BoundTwitchUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<string[]>(json) ?? fallback;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateMemberFields(string login, string displayName, string role)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(login) || login.Length > 80)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Login ist erforderlich und darf maximal 80 Zeichen haben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!login.All(value => char.IsLetterOrDigit(value) || value is '_' or '-' or '.'))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Login darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 120)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Anzeigename ist erforderlich und darf maximal 120 Zeichen haben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AdminRoles.IsKnownRole(role) || role == AdminRoles.Viewer || role == AdminRoles.ContentAdmin)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte wähle Owner, Creator, Admins, Mitglied, Reviewer oder Organisation Team." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult?> ValidatePrivilegedMemberChangeAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int? memberId,
|
||||||
|
string login,
|
||||||
|
string role,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (role == AdminRoles.Owner)
|
||||||
|
{
|
||||||
|
if (!string.Equals(login, PersonalOwnerLogin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Die Owner-Rolle ist nur für @{PersonalOwnerLogin} vorgesehen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerExists = await db.TeamMembers.AnyAsync(
|
||||||
|
item => item.Role == AdminRoles.Owner && (!memberId.HasValue || item.Id != memberId.Value),
|
||||||
|
cancellationToken);
|
||||||
|
return ownerExists
|
||||||
|
? Results.Conflict(new { message = "Es darf nur einen Owner-Account geben." })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role != AdminRoles.Creator)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PersonalCreatorLogins.Contains(login, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Die Creator-Rolle ist nur für @{string.Join(" oder @", PersonalCreatorLogins)} vorgesehen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var creatorExists = await db.TeamMembers.AnyAsync(
|
||||||
|
item => item.Role == AdminRoles.Creator && (!memberId.HasValue || item.Id != memberId.Value),
|
||||||
|
cancellationToken);
|
||||||
|
return creatorExists
|
||||||
|
? Results.Conflict(new { message = "Es darf nur einen Creator-Account geben." })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeactivateMemberSessions(AwardsDbContext db, TeamMember member, string? previousLogin = null)
|
||||||
|
{
|
||||||
|
var teamSessionId = $"team:{member.Login}";
|
||||||
|
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
|
||||||
|
var twitchUserId = member.BoundTwitchUserId;
|
||||||
|
foreach (var session in db.UserSessions.Where(item =>
|
||||||
|
item.TwitchUserId == teamSessionId
|
||||||
|
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|
||||||
|
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
|
||||||
|
{
|
||||||
|
session.IsActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOwnTeamAccount(UserSession session, TeamMember member)
|
||||||
|
{
|
||||||
|
var teamLogin = AuthEndpoints.ReadTeamLoginFromSession(session.TwitchUserId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(teamLogin)
|
||||||
|
&& string.Equals(teamLogin, member.Login, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.IsNullOrWhiteSpace(member.BoundTwitchUserId)
|
||||||
|
&& string.Equals(session.TwitchUserId, member.BoundTwitchUserId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanDeletePrivilegedMember(UserSession session, TeamMember member)
|
||||||
|
{
|
||||||
|
if (IsOwnTeamAccount(session, member))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdminRoles.Normalize(session.Role) == AdminRoles.Creator
|
||||||
|
&& AdminRoles.Normalize(member.Role) == AdminRoles.Owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SyncMemberSessionRolesAsync(AwardsDbContext db, TeamMember member, string? previousLogin, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var teamSessionId = $"team:{member.Login}";
|
||||||
|
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
|
||||||
|
var twitchUserId = member.BoundTwitchUserId;
|
||||||
|
var sessions = await db.UserSessions
|
||||||
|
.Where(item => item.IsActive
|
||||||
|
&& (item.TwitchUserId == teamSessionId
|
||||||
|
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|
||||||
|
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
if (session.TwitchUserId == previousTeamSessionId)
|
||||||
|
{
|
||||||
|
session.TwitchUserId = teamSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.DisplayName = member.DisplayName;
|
||||||
|
session.Role = AdminRoles.Normalize(member.Role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLogin(string value) =>
|
||||||
|
value.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeDisplayName(string value) =>
|
||||||
|
value.Trim();
|
||||||
|
|
||||||
|
private static string GeneratePassword()
|
||||||
|
{
|
||||||
|
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!?#%";
|
||||||
|
Span<char> chars = stackalloc char[MinPasswordLength];
|
||||||
|
Span<byte> bytes = stackalloc byte[MinPasswordLength];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
|
||||||
|
for (var index = 0; index < chars.Length; index++)
|
||||||
|
{
|
||||||
|
chars[index] = alphabet[bytes[index] % alphabet.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Backend.Data;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> DeleteMyParticipationData(
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var teamMember = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||||
|
if (teamMember is not null)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Team-Accounts werden nicht ueber die automatische Teilnahme-Datenloeschung entfernt." },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchUserId = session.TwitchUserId;
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var ballotIds = await db.VoteBallots
|
||||||
|
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.ToArrayAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var deletedVoteEntries = ballotIds.Length == 0
|
||||||
|
? 0
|
||||||
|
: await db.VoteEntries
|
||||||
|
.Where(item => ballotIds.Contains(item.BallotId))
|
||||||
|
.ExecuteDeleteAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var deletedBallots = await db.VoteBallots
|
||||||
|
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||||
|
.ExecuteDeleteAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var deletedNominations = await db.Nominations
|
||||||
|
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||||
|
.ExecuteDeleteAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var deletedClips = await db.ClipSubmissions
|
||||||
|
.Where(item => item.SubmittedByTwitchId == twitchUserId)
|
||||||
|
.ExecuteDeleteAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var deletedRiskFlags = await db.RiskFlags
|
||||||
|
.Where(item => item.TwitchUserId == twitchUserId)
|
||||||
|
.ExecuteDeleteAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var disabledSessions = await db.UserSessions
|
||||||
|
.Where(item => item.TwitchUserId == twitchUserId)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(item => item.IsActive, false),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
deleted = true,
|
||||||
|
twitchUserId,
|
||||||
|
deletedVoteEntries,
|
||||||
|
deletedBallots,
|
||||||
|
deletedNominations,
|
||||||
|
deletedClips,
|
||||||
|
deletedRiskFlags,
|
||||||
|
disabledSessions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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 partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> DemoLogin(
|
||||||
|
HttpContext context,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IConfiguration configuration,
|
||||||
|
DemoLoginRequest request,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
|
IRiskFlagService riskFlagService,
|
||||||
|
IRiskRuleService riskRuleService)
|
||||||
|
{
|
||||||
|
var login = request.Login?.Trim() ?? request.Email?.Trim() ?? string.Empty;
|
||||||
|
var password = request.Password ?? string.Empty;
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
|
var databaseDemoConfigured = settings is not null
|
||||||
|
&& settings.DemoLoginManagedByDatabase;
|
||||||
|
|
||||||
|
string twitchUserId;
|
||||||
|
string displayName;
|
||||||
|
bool credentialsMatch;
|
||||||
|
var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration);
|
||||||
|
var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL");
|
||||||
|
var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||||
|
var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID");
|
||||||
|
var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME");
|
||||||
|
|
||||||
|
if (databaseDemoConfigured && settings is not null)
|
||||||
|
{
|
||||||
|
if (!settings.DemoLoginEnabled)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasDatabaseDemoCredentials(settings)
|
||||||
|
|| string.IsNullOrWhiteSpace(settings.DemoLoginTwitchUserId)
|
||||||
|
|| string.IsNullOrWhiteSpace(settings.DemoLoginDisplayName))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Demo login is not fully configured." },
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialsMatch = LoginMatchesIdentifier(
|
||||||
|
login,
|
||||||
|
settings.DemoLoginEmail,
|
||||||
|
settings.DemoLoginTwitchUserId,
|
||||||
|
settings.DemoLoginDisplayName)
|
||||||
|
&& DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt);
|
||||||
|
twitchUserId = settings.DemoLoginTwitchUserId.Trim();
|
||||||
|
displayName = settings.DemoLoginDisplayName.Trim();
|
||||||
|
|
||||||
|
if (!credentialsMatch
|
||||||
|
&& environment.IsDevelopment()
|
||||||
|
&& IsDemoLoginEnabled(configuration)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||||
|
{
|
||||||
|
credentialsMatch = LoginMatchesIdentifier(
|
||||||
|
login,
|
||||||
|
fallbackConfiguredLogin,
|
||||||
|
fallbackConfiguredEmail,
|
||||||
|
fallbackConfiguredTwitchUserId,
|
||||||
|
fallbackConfiguredDisplayName)
|
||||||
|
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||||
|
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||||
|
displayName = fallbackConfiguredDisplayName.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!IsDemoLoginEnabled(configuration))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin)
|
||||||
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredPassword)
|
||||||
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId)
|
||||||
|
|| string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Demo login is not fully configured." },
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialsMatch = LoginMatchesIdentifier(
|
||||||
|
login,
|
||||||
|
fallbackConfiguredLogin,
|
||||||
|
fallbackConfiguredEmail,
|
||||||
|
fallbackConfiguredTwitchUserId,
|
||||||
|
fallbackConfiguredDisplayName)
|
||||||
|
&& DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword);
|
||||||
|
twitchUserId = fallbackConfiguredTwitchUserId.Trim();
|
||||||
|
displayName = fallbackConfiguredDisplayName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credentialsMatch)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
|
var session = await userSessionService.CreateSessionAsync(
|
||||||
|
twitchUserId,
|
||||||
|
displayName,
|
||||||
|
AdminRoles.Owner,
|
||||||
|
requestMetadata,
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
var rapidDemoLoginRule = await riskRuleService.GetRuleAsync("rapid_demo_login_ip", context.RequestAborted);
|
||||||
|
var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync(
|
||||||
|
requestMetadata.ClientIp,
|
||||||
|
DateTimeOffset.UtcNow.AddMinutes(-rapidDemoLoginRule.WindowMinutes),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
if (rapidDemoLoginRule.Enabled && recentSessionsFromIp >= rapidDemoLoginRule.Threshold)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
null,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"login",
|
||||||
|
"rapid_demo_login_ip",
|
||||||
|
rapidDemoLoginRule.Severity,
|
||||||
|
"Mehrere Demo-Admin-Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
|
||||||
|
requestMetadata,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
recentSessionsFromIp,
|
||||||
|
threshold = rapidDemoLoginRule.Threshold,
|
||||||
|
windowMinutes = rapidDemoLoginRule.WindowMinutes,
|
||||||
|
entityLinks = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
label = "Audit-Log öffnen",
|
||||||
|
entityType = "session",
|
||||||
|
entityId = session.TwitchUserId,
|
||||||
|
to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context.RequestAborted);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
|
||||||
|
?? configuration["DemoAdmin:Enabled"];
|
||||||
|
|
||||||
|
return bool.TryParse(rawValue, out var enabled) && enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||||
|
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
|
||||||
|
|
||||||
|
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
|
||||||
|
return string.IsNullOrWhiteSpace(configuredLogin)
|
||||||
|
? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL")
|
||||||
|
: configuredLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasDatabaseDemoCredentials(SiteSettings settings) =>
|
||||||
|
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
|
||||||
|
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
||||||
|
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
|
||||||
|
|
||||||
|
private static bool LoginMatchesIdentifier(string login, params string?[] validIdentifiers)
|
||||||
|
{
|
||||||
|
var normalizedLogin = NormalizeLoginIdentifier(login);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedLogin))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validIdentifiers
|
||||||
|
.Select(NormalizeLoginIdentifier)
|
||||||
|
.Any(identifier => string.Equals(normalizedLogin, identifier, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLoginIdentifier(string? value) =>
|
||||||
|
(value ?? string.Empty).Trim().TrimStart('@');
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private const int MaxTwitchUserIdLength = 64;
|
||||||
|
private const int MaxDisplayNameLength = 80;
|
||||||
|
|
||||||
|
private static async Task<IResult> DevLogin(
|
||||||
|
HttpContext context,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
LoginRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
|
IRiskFlagService riskFlagService,
|
||||||
|
IRiskRuleService riskRuleService)
|
||||||
|
{
|
||||||
|
if (!environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedTwitchUserId = request.TwitchUserId?.Trim() ?? string.Empty;
|
||||||
|
var normalizedDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||||
|
var normalizedRole = AdminRoles.Normalize(request.Role);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedTwitchUserId) || normalizedTwitchUserId.Length > MaxTwitchUserIdLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Twitch user id is required and must stay below {MaxTwitchUserIdLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedTwitchUserId.All(value => char.IsLetterOrDigit(value) || value is '_' or '-'))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Twitch user id contains unsupported characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedDisplayName) || normalizedDisplayName.Length > MaxDisplayNameLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxDisplayNameLength} characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AdminRoles.IsKnownRole(request.Role))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Role must be viewer, content_admin, admin or owner." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
|
var session = await userSessionService.CreateDevSessionAsync(
|
||||||
|
request with
|
||||||
|
{
|
||||||
|
TwitchUserId = normalizedTwitchUserId,
|
||||||
|
DisplayName = normalizedDisplayName,
|
||||||
|
Role = normalizedRole,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
var rapidLoginRule = await riskRuleService.GetRuleAsync("rapid_login_ip", context.RequestAborted);
|
||||||
|
var recentSessionsFromIp = await userSessionService.CountRecentSessionsFromIpAsync(
|
||||||
|
requestMetadata.ClientIp,
|
||||||
|
DateTimeOffset.UtcNow.AddMinutes(-rapidLoginRule.WindowMinutes),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
if (rapidLoginRule.Enabled && recentSessionsFromIp >= rapidLoginRule.Threshold)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
null,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"login",
|
||||||
|
"rapid_login_ip",
|
||||||
|
rapidLoginRule.Severity,
|
||||||
|
"Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
|
||||||
|
requestMetadata,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
recentSessionsFromIp,
|
||||||
|
threshold = rapidLoginRule.Threshold,
|
||||||
|
windowMinutes = rapidLoginRule.WindowMinutes,
|
||||||
|
entityLinks = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
label = "Audit-Log öffnen",
|
||||||
|
entityType = "session",
|
||||||
|
entityId = session.TwitchUserId,
|
||||||
|
to = $"/admin/users-logs?query={Uri.EscapeDataString(session.TwitchUserId)}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context.RequestAborted);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/auth");
|
||||||
|
|
||||||
|
group.MapPost("/dev-login", DevLogin)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("DevLogin")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/demo-login", DemoLogin)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("DemoLogin")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/team-login", TeamLogin)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("TeamLogin")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/password/change", ChangePassword)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("ChangePassword")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/twitch/authorize", StartTwitchAuthorization)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("StartTwitchAuthorization")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapDelete("/twitch/binding", DisconnectTwitchBinding)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("DisconnectTwitchBinding")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/twitch/callback", CompleteTwitchAuthorization)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("CompleteTwitchAuthorization")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/session", GetSession)
|
||||||
|
.WithName("GetSession")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/logout", Logout)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("Logout")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapDelete("/me/data", DeleteMyParticipationData)
|
||||||
|
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||||
|
.WithName("DeleteMyParticipationData")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetSession(
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Ok(new { loggedOut = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await userSessionService.LogoutAsync(session, context.RequestAborted);
|
||||||
|
return Results.Ok(new { loggedOut = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
|
UserSession session,
|
||||||
|
bool mustChangePassword = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||||
|
var sessionRole = teamMember?.Role ?? session.Role;
|
||||||
|
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||||
|
var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken);
|
||||||
|
return new(
|
||||||
|
session.SessionToken,
|
||||||
|
session.TwitchUserId,
|
||||||
|
teamMember?.DisplayName ?? session.DisplayName,
|
||||||
|
AdminRoles.Normalize(sessionRole),
|
||||||
|
permissionKeys,
|
||||||
|
sessionIdleTimeoutHours,
|
||||||
|
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||||
|
teamMember?.Login,
|
||||||
|
teamMember?.BoundTwitchUserId,
|
||||||
|
teamMember?.BoundTwitchDisplayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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 partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private const string TeamSessionPrefix = "team:";
|
||||||
|
private const int MinTeamPasswordLength = 10;
|
||||||
|
|
||||||
|
private static async Task<IResult> TeamLogin(
|
||||||
|
HttpContext context,
|
||||||
|
TeamLoginRequest? request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Login und Passwort sind erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
|
||||||
|
if (member is null
|
||||||
|
|| !member.IsActive
|
||||||
|
|| !DemoCredentialHasher.VerifyPassword(password, member.PasswordHash, member.PasswordSalt))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
var session = await userSessionService.CreateSessionAsync(
|
||||||
|
BuildTeamSessionId(member.Login),
|
||||||
|
member.DisplayName,
|
||||||
|
member.Role,
|
||||||
|
RequestMetadataReader.Read(context),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ChangePassword(
|
||||||
|
HttpContext context,
|
||||||
|
ChangePasswordRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Dieser Account nutzt keinen aktiven Team-Login." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPassword = request.CurrentPassword ?? string.Empty;
|
||||||
|
var newPassword = request.NewPassword?.Trim() ?? string.Empty;
|
||||||
|
if (!DemoCredentialHasher.VerifyPassword(currentPassword, member.PasswordHash, member.PasswordSalt))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das aktuelle Passwort stimmt nicht." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.Length < MinTeamPasswordLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Das neue Passwort muss mindestens {MinTeamPasswordLength} Zeichen lang sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DemoCredentialHasher.FixedTimePlainTextEquals(currentPassword, newPassword))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das neue Passwort muss sich vom aktuellen Passwort unterscheiden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials = DemoCredentialHasher.HashPassword(newPassword);
|
||||||
|
member.PasswordHash = credentials.Hash;
|
||||||
|
member.PasswordSalt = credentials.Salt;
|
||||||
|
member.MustChangePassword = false;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
session.DisplayName = member.DisplayName;
|
||||||
|
session.Role = AdminRoles.Normalize(member.Role);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTeamSessionId(string login) =>
|
||||||
|
$"{TeamSessionPrefix}{login}";
|
||||||
|
|
||||||
|
internal static string ReadTeamLoginFromSession(string twitchUserId) =>
|
||||||
|
twitchUserId.StartsWith(TeamSessionPrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? NormalizeTeamLogin(twitchUserId[TeamSessionPrefix.Length..])
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
internal static async Task<TeamMember?> FindTeamMemberForSessionAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
UserSession session,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var login = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(login))
|
||||||
|
{
|
||||||
|
return await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchUserId = NormalizeTwitchUserId(session.TwitchUserId);
|
||||||
|
return string.IsNullOrWhiteSpace(twitchUserId)
|
||||||
|
? null
|
||||||
|
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeTeamLogin(string? value) =>
|
||||||
|
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeTwitchUserId(string? value) =>
|
||||||
|
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Configuration;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private const string TwitchLoginPurpose = "team-login";
|
||||||
|
private const string TwitchBindingPurpose = "team-binding";
|
||||||
|
private const string TwitchStateCachePrefix = "twitch-oauth-state:";
|
||||||
|
private static readonly TimeSpan TwitchStateLifetime = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
private static async Task<IResult> StartTwitchAuthorization(
|
||||||
|
HttpContext context,
|
||||||
|
TwitchAuthorizeRequest request,
|
||||||
|
IMemoryCache memoryCache,
|
||||||
|
IOptions<TwitchAuthOptions> twitchOptions,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
|
||||||
|
if (!TwitchAuthConfigured(options))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Twitch OAuth ist noch nicht konfiguriert. Bitte TwitchAuth:ClientId und TwitchAuth:ClientSecret setzen." },
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
var purpose = NormalizeTwitchPurpose(request.Purpose);
|
||||||
|
if (purpose is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Unbekannter Twitch-Login-Zweck." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var frontendOrigin = NormalizeFrontendOrigin(request.FrontendOrigin, configuration, environment);
|
||||||
|
if (frontendOrigin is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Frontend-Origin ist fuer Twitch OAuth nicht erlaubt." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionToken = string.Empty;
|
||||||
|
if (purpose == TwitchBindingPurpose)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken = session.SessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||||
|
memoryCache.Set(
|
||||||
|
$"{TwitchStateCachePrefix}{state}",
|
||||||
|
new TwitchOAuthState(purpose, NormalizeReturnUrl(request.ReturnUrl), frontendOrigin, sessionToken),
|
||||||
|
TwitchStateLifetime);
|
||||||
|
|
||||||
|
var authorizationParams = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["client_id"] = options.ClientId,
|
||||||
|
["redirect_uri"] = ResolveRedirectUri(context, options),
|
||||||
|
["response_type"] = "code",
|
||||||
|
["state"] = state,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Scope))
|
||||||
|
{
|
||||||
|
authorizationParams["scope"] = options.Scope.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorizationUrl = QueryHelpers.AddQueryString(
|
||||||
|
"https://id.twitch.tv/oauth2/authorize",
|
||||||
|
authorizationParams);
|
||||||
|
|
||||||
|
return Results.Ok(new TwitchAuthorizeResponse(authorizationUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CompleteTwitchAuthorization(
|
||||||
|
HttpContext context,
|
||||||
|
string? code,
|
||||||
|
string? state,
|
||||||
|
string? error,
|
||||||
|
string? error_description,
|
||||||
|
IMemoryCache memoryCache,
|
||||||
|
IOptions<TwitchAuthOptions> twitchOptions,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(state)
|
||||||
|
|| !memoryCache.TryGetValue<TwitchOAuthState>($"{TwitchStateCachePrefix}{state}", out var oauthState)
|
||||||
|
|| oauthState is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError("/", "Der Twitch-Login ist abgelaufen. Bitte starte ihn erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.Remove($"{TwitchStateCachePrefix}{state}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(error))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(
|
||||||
|
oauthState,
|
||||||
|
string.IsNullOrWhiteSpace(error_description)
|
||||||
|
? "Twitch hat den Login abgebrochen."
|
||||||
|
: error_description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen Login-Code zurueckgegeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
|
||||||
|
if (!TwitchAuthConfigured(options))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch OAuth ist auf dem Server nicht vollstaendig konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = httpClientFactory.CreateClient();
|
||||||
|
var token = await ExchangeTwitchCodeAsync(httpClient, context, options, code, context.RequestAborted);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte den Login-Code nicht bestaetigen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchUser = await LoadTwitchUserAsync(httpClient, options.ClientId, token.AccessToken, context.RequestAborted);
|
||||||
|
if (twitchUser is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte dein Profil nicht laden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchLogin = NormalizeTwitchUserId(twitchUser.Login);
|
||||||
|
if (string.IsNullOrWhiteSpace(twitchLogin))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen gueltigen Login-Namen geliefert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauthState.Purpose == TwitchBindingPurpose)
|
||||||
|
{
|
||||||
|
return await CompleteTwitchBindingAsync(context, oauthState, twitchLogin, twitchUser.DisplayName, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await CompleteTwitchTeamLoginAsync(context, oauthState, twitchLogin, db, userSessionService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CompleteTwitchBindingAsync(
|
||||||
|
HttpContext context,
|
||||||
|
TwitchOAuthState oauthState,
|
||||||
|
string twitchLogin,
|
||||||
|
string twitchDisplayName,
|
||||||
|
AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var session = await db.UserSessions.FirstOrDefaultAsync(
|
||||||
|
item => item.SessionToken == oauthState.SessionToken && item.IsActive,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Deine Team-Session ist abgelaufen. Bitte melde dich erneut an.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Dieser Account ist kein aktiver Team-Login.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.MustChangePassword)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Bitte aendere zuerst dein temporaeres Passwort.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var alreadyBound = await db.TeamMembers.AnyAsync(
|
||||||
|
item => item.Id != member.Id && item.BoundTwitchUserId == twitchLogin,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (alreadyBound)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Dieser Twitch-Account ist bereits mit einem Team-Account verbunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousTwitchUserId = member.BoundTwitchUserId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
|
||||||
|
&& !string.Equals(previousTwitchUserId, twitchLogin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
|
||||||
|
{
|
||||||
|
oldSession.IsActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
member.BoundTwitchUserId = twitchLogin;
|
||||||
|
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchLogin : twitchDisplayName;
|
||||||
|
member.TwitchBoundAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return RedirectToTwitchCallback(oauthState, "connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DisconnectTwitchBinding(
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Dieser Account nutzt keinen aktiven Team-Login." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var boundTwitchUserId = NormalizeTwitchUserId(member.BoundTwitchUserId);
|
||||||
|
if (string.IsNullOrWhiteSpace(boundTwitchUserId))
|
||||||
|
{
|
||||||
|
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||||
|
var currentSessionUsesBoundTwitch = string.IsNullOrWhiteSpace(currentTeamLogin)
|
||||||
|
&& string.Equals(NormalizeTwitchUserId(session.TwitchUserId), boundTwitchUserId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var linkedSession in db.UserSessions.Where(item => item.TwitchUserId == boundTwitchUserId))
|
||||||
|
{
|
||||||
|
linkedSession.IsActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
member.BoundTwitchUserId = null;
|
||||||
|
member.BoundTwitchDisplayName = null;
|
||||||
|
member.TwitchBoundAt = null;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
if (currentSessionUsesBoundTwitch)
|
||||||
|
{
|
||||||
|
session.IsActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
return Results.Ok(new TwitchBindingDisconnectResponse(
|
||||||
|
true,
|
||||||
|
currentSessionUsesBoundTwitch,
|
||||||
|
currentSessionUsesBoundTwitch
|
||||||
|
? null
|
||||||
|
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||||
|
HttpContext context,
|
||||||
|
TwitchOAuthState oauthState,
|
||||||
|
string twitchLogin,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(
|
||||||
|
item => item.BoundTwitchUserId == twitchLogin,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Fuer diesen Twitch-Account ist kein aktiver Team-Account gebunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
var session = await userSessionService.CreateSessionAsync(
|
||||||
|
twitchLogin,
|
||||||
|
member.DisplayName,
|
||||||
|
member.Role,
|
||||||
|
RequestMetadataReader.Read(context),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
return RedirectToTwitchCallback(oauthState, "authenticated", session.SessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TwitchTokenResponse?> ExchangeTwitchCodeAsync(
|
||||||
|
HttpClient httpClient,
|
||||||
|
HttpContext context,
|
||||||
|
TwitchAuthOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.PostAsync(
|
||||||
|
"https://id.twitch.tv/oauth2/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["client_id"] = options.ClientId,
|
||||||
|
["client_secret"] = options.ClientSecret,
|
||||||
|
["code"] = code,
|
||||||
|
["grant_type"] = "authorization_code",
|
||||||
|
["redirect_uri"] = ResolveRedirectUri(context, options),
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
return await JsonSerializer.DeserializeAsync<TwitchTokenResponse>(stream, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TwitchUserResponseItem?> LoadTwitchUserAsync(
|
||||||
|
HttpClient httpClient,
|
||||||
|
string clientId,
|
||||||
|
string accessToken,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.twitch.tv/helix/users");
|
||||||
|
request.Headers.Add("Client-Id", clientId);
|
||||||
|
request.Headers.Authorization = new("Bearer", accessToken);
|
||||||
|
|
||||||
|
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
var payload = await JsonSerializer.DeserializeAsync<TwitchUsersResponse>(stream, cancellationToken: cancellationToken);
|
||||||
|
return payload?.Data.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RedirectToTwitchCallback(TwitchOAuthState state, string status, string? sessionToken = null, string? message = null)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["status"] = status,
|
||||||
|
["returnUrl"] = state.ReturnUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionToken))
|
||||||
|
{
|
||||||
|
values["sessionToken"] = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
values["message"] = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragment = string.Join('&', values.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value)}"));
|
||||||
|
return Results.Redirect($"{state.FrontendOrigin}/auth/twitch/callback#{fragment}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RedirectToTwitchCallbackError(TwitchOAuthState state, string message) =>
|
||||||
|
RedirectToTwitchCallback(state, "error", message: message);
|
||||||
|
|
||||||
|
private static IResult RedirectToTwitchCallbackError(string returnUrl, string message) =>
|
||||||
|
RedirectToTwitchCallback(new TwitchOAuthState(TwitchLoginPurpose, NormalizeReturnUrl(returnUrl), ApplicationDefaults.FrontendOrigins[0], string.Empty), "error", message: message);
|
||||||
|
|
||||||
|
private static bool TwitchAuthConfigured(TwitchAuthOptions options) =>
|
||||||
|
!string.IsNullOrWhiteSpace(options.ClientId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(options.ClientSecret);
|
||||||
|
|
||||||
|
private static async Task<TwitchAuthOptions> ResolveEffectiveTwitchOptionsAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
TwitchAuthOptions configuredOptions,
|
||||||
|
IConfiguration configuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||||
|
return new TwitchAuthOptions
|
||||||
|
{
|
||||||
|
ClientId = FirstConfigured(settings?.TwitchClientId, ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID"), configuredOptions.ClientId),
|
||||||
|
ClientSecret = FirstConfigured(settings?.TwitchClientSecret, ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"), configuredOptions.ClientSecret),
|
||||||
|
RedirectUri = FirstConfigured(settings?.TwitchRedirectUri, ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI"), configuredOptions.RedirectUri),
|
||||||
|
Scope = FirstConfigured(settings?.TwitchScope, ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE"), configuredOptions.Scope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstConfigured(params string?[] values) =>
|
||||||
|
values.Select(value => value?.Trim() ?? string.Empty).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
|
||||||
|
|
||||||
|
private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||||
|
configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty;
|
||||||
|
|
||||||
|
private static string ResolveRedirectUri(HttpContext context, TwitchAuthOptions options)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.RedirectUri))
|
||||||
|
{
|
||||||
|
return options.RedirectUri.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{context.Request.Scheme}://{context.Request.Host}/api/auth/twitch/callback";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeTwitchPurpose(string? purpose)
|
||||||
|
{
|
||||||
|
var normalized = (purpose ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized is TwitchLoginPurpose or TwitchBindingPurpose ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeReturnUrl(string? returnUrl)
|
||||||
|
{
|
||||||
|
var normalized = (returnUrl ?? "/admin").Trim();
|
||||||
|
return normalized.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
&& !normalized.StartsWith("//", StringComparison.Ordinal)
|
||||||
|
&& !normalized.StartsWith("/auth/twitch/callback", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? normalized
|
||||||
|
: "/admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeFrontendOrigin(
|
||||||
|
string? frontendOrigin,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(frontendOrigin?.Trim(), UriKind.Absolute, out var uri)
|
||||||
|
|| uri.Scheme is not ("http" or "https")
|
||||||
|
|| string.IsNullOrWhiteSpace(uri.Host))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var origin = uri.GetLeftPart(UriPartial.Authority);
|
||||||
|
var configuredOrigins = configuration
|
||||||
|
.GetSection(FrontendOptions.SectionName)
|
||||||
|
.Get<FrontendOptions>()?
|
||||||
|
.AllowedOrigins ?? [];
|
||||||
|
var allowedOrigins = configuredOrigins.Length > 0 || !environment.IsDevelopment()
|
||||||
|
? configuredOrigins
|
||||||
|
: ApplicationDefaults.FrontendOrigins;
|
||||||
|
|
||||||
|
return allowedOrigins.Any(item => string.Equals(item.TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
? origin
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TwitchOAuthState(
|
||||||
|
string Purpose,
|
||||||
|
string ReturnUrl,
|
||||||
|
string FrontendOrigin,
|
||||||
|
string SessionToken);
|
||||||
|
|
||||||
|
private sealed record TwitchTokenResponse(
|
||||||
|
[property: JsonPropertyName("access_token")] string AccessToken);
|
||||||
|
|
||||||
|
private sealed record TwitchUsersResponse(
|
||||||
|
[property: JsonPropertyName("data")] TwitchUserResponseItem[] Data);
|
||||||
|
|
||||||
|
private sealed record TwitchUserResponseItem(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("login")] string Login,
|
||||||
|
[property: JsonPropertyName("display_name")] string DisplayName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> CreateClip(
|
||||||
|
HttpContext context,
|
||||||
|
CreateClipRequest? request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
|
IRiskFlagService riskFlagService,
|
||||||
|
IRiskRuleService riskRuleService)
|
||||||
|
{
|
||||||
|
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." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var platform = ResolveClipPlatform(clipUrl);
|
||||||
|
if (platform is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var siteSettings = await db.SiteSettings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
|
if (siteSettings is null)
|
||||||
|
{
|
||||||
|
return Results.Problem("Site settings are missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!siteSettings.ClipSubmissionsEnabled)
|
||||||
|
{
|
||||||
|
var message = string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||||
|
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||||
|
: siteSettings.ClipSubmissionDisabledMessage;
|
||||||
|
return Results.BadRequest(new { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == validatedRequest.Year);
|
||||||
|
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||||
|
if (clipSeasonResolution.Result is not null)
|
||||||
|
{
|
||||||
|
return clipSeasonResolution.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
season = clipSeasonResolution.Season!;
|
||||||
|
|
||||||
|
var selectedCandidate = validatedRequest.CandidateId is int candidateId
|
||||||
|
? await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
|
||||||
|
: null;
|
||||||
|
if (validatedRequest.CandidateId is not null && selectedCandidate is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validatedRequest.CategoryId is int requestedCategoryId
|
||||||
|
&& selectedCandidate is not null
|
||||||
|
&& selectedCandidate.CategoryId != requestedCategoryId)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedCategoryId = validatedRequest.CategoryId ?? selectedCandidate?.CategoryId;
|
||||||
|
var normalizedTitle = validatedRequest.Title?.Trim() ?? string.Empty;
|
||||||
|
var submittedCreator = validatedRequest.Creator?.Trim();
|
||||||
|
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
|
||||||
|
? selectedCandidate?.DisplayName ?? string.Empty
|
||||||
|
: submittedCreator;
|
||||||
|
if (normalizedTitle.Length > 160)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Clip titles must stay below 160 characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedCreator.Length > 160)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Creator names must stay below 160 characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedCategoryId is int categoryId)
|
||||||
|
{
|
||||||
|
var categoryExists = selectedCandidate?.CategoryId == categoryId
|
||||||
|
|| await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
|
||||||
|
if (!categoryExists)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
|
||||||
|
if (submitterIdResult.Result is not null)
|
||||||
|
{
|
||||||
|
return submitterIdResult.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var submitterId = submitterIdResult.SubmitterId!;
|
||||||
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
|
var duplicateClipRule = await riskRuleService.GetRuleAsync("duplicate_clip_submission", context.RequestAborted);
|
||||||
|
var rapidClipBurstRule = await riskRuleService.GetRuleAsync("rapid_clip_burst", context.RequestAborted);
|
||||||
|
var recentClipSubmissions = await db.ClipSubmissions.CountAsync(item =>
|
||||||
|
item.SubmittedByTwitchId == submitterId
|
||||||
|
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidClipBurstRule.WindowMinutes));
|
||||||
|
var alreadySubmittedClip = await db.ClipSubmissions.AnyAsync(item =>
|
||||||
|
item.SeasonId == season.Id
|
||||||
|
&& item.SubmittedByTwitchId == submitterId
|
||||||
|
&& item.ClipUrl == clipUrl);
|
||||||
|
|
||||||
|
var clip = new ClipSubmission
|
||||||
|
{
|
||||||
|
SeasonId = season.Id,
|
||||||
|
CategoryId = resolvedCategoryId,
|
||||||
|
CandidateId = selectedCandidate?.Id,
|
||||||
|
SubmittedByTwitchId = submitterId,
|
||||||
|
ClipUrl = clipUrl,
|
||||||
|
Title = normalizedTitle,
|
||||||
|
Creator = normalizedCreator,
|
||||||
|
Platform = platform,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedFromIp = requestMetadata.ClientIp,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ClipSubmissions.Add(clip);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var clipLink = new
|
||||||
|
{
|
||||||
|
label = "Clip öffnen",
|
||||||
|
entityType = "clip",
|
||||||
|
entityId = clip.Id.ToString(),
|
||||||
|
to = $"/admin/clips?query={Uri.EscapeDataString(clip.Id.ToString())}",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (alreadySubmittedClip && duplicateClipRule.Enabled)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
season.Id,
|
||||||
|
submitterId,
|
||||||
|
"clip",
|
||||||
|
"duplicate_clip_submission",
|
||||||
|
duplicateClipRule.Severity,
|
||||||
|
"Ein User hat denselben Clip erneut eingereicht.",
|
||||||
|
requestMetadata,
|
||||||
|
new { clipId = clip.Id, clipUrl, CategoryId = resolvedCategoryId, CandidateId = selectedCandidate?.Id, entityLinks = new[] { clipLink } },
|
||||||
|
context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rapidClipBurstRule.Enabled && recentClipSubmissions >= rapidClipBurstRule.Threshold)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
season.Id,
|
||||||
|
submitterId,
|
||||||
|
"clip",
|
||||||
|
"rapid_clip_burst",
|
||||||
|
rapidClipBurstRule.Severity,
|
||||||
|
"Ungewoehnlich viele Clip-Einreichungen in kurzer Zeit erkannt.",
|
||||||
|
requestMetadata,
|
||||||
|
new { clipId = clip.Id, recentClipSubmissions, threshold = rapidClipBurstRule.Threshold, windowMinutes = rapidClipBurstRule.WindowMinutes, entityLinks = new[] { clipLink } },
|
||||||
|
context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, clipId = clip.Id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.Http.Extensions;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private readonly record struct SubmitterIdResolution(string? SubmitterId, IResult? Result);
|
||||||
|
private readonly record struct PublicWriteSeasonResolution(Season? Season, IResult? Result);
|
||||||
|
private sealed record PublicCandidateClip(
|
||||||
|
int? CategoryId,
|
||||||
|
int? CandidateId,
|
||||||
|
string Creator,
|
||||||
|
string ClipUrl,
|
||||||
|
string Title,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? ReviewedAt);
|
||||||
|
|
||||||
|
private static async Task<SubmitterIdResolution> ResolveSubmitterIdAsync(
|
||||||
|
HttpContext context,
|
||||||
|
string? fallbackTwitchUserId,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return new SubmitterIdResolution(
|
||||||
|
null,
|
||||||
|
Results.Json(
|
||||||
|
new { message = "A logged in user is required to submit this action." },
|
||||||
|
statusCode: StatusCodes.Status401Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
var submittedTwitchUserId = NormalizeSubmittedTwitchUserId(fallbackTwitchUserId);
|
||||||
|
if (submittedTwitchUserId is not null
|
||||||
|
&& !string.Equals(submittedTwitchUserId, session.TwitchUserId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new SubmitterIdResolution(
|
||||||
|
null,
|
||||||
|
Results.BadRequest(new { message = "Submitted user identity does not match the active session." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SubmitterIdResolution(session.TwitchUserId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeSubmittedTwitchUserId(string? twitchUserId)
|
||||||
|
{
|
||||||
|
var normalized = twitchUserId?.Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PublicWriteSeasonResolution EnsurePublicWriteSeason(
|
||||||
|
Season? season,
|
||||||
|
params string[] allowedPhaseKeys)
|
||||||
|
{
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return new PublicWriteSeasonResolution(null, Results.BadRequest(new { message = "The selected season does not exist." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!season.IsCurrent)
|
||||||
|
{
|
||||||
|
return new PublicWriteSeasonResolution(
|
||||||
|
null,
|
||||||
|
Results.BadRequest(new { message = "Submissions are only allowed for the active season." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPhaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
|
if (!allowedPhaseKeys.Contains(currentPhaseKey, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var phaseLabel = DescribePublicPhase(currentPhaseKey);
|
||||||
|
return new PublicWriteSeasonResolution(
|
||||||
|
null,
|
||||||
|
Results.BadRequest(new
|
||||||
|
{
|
||||||
|
message = $"This action is not available during the current season phase ({phaseLabel}).",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PublicWriteSeasonResolution(season, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeExternalUrl(string? rawUrl, out string normalizedUrl)
|
||||||
|
{
|
||||||
|
normalizedUrl = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.Scheme is not ("http" or "https"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedUrl = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveClipPlatform(string clipUrl)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = uri.Host.ToLowerInvariant();
|
||||||
|
if (host is "twitch.tv" or "www.twitch.tv" or "clips.twitch.tv")
|
||||||
|
{
|
||||||
|
return "Twitch";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host is "youtube.com" or "www.youtube.com" or "m.youtube.com" or "youtu.be")
|
||||||
|
{
|
||||||
|
return "YouTube";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldExposePublicCategory(string phaseKey, int candidateCount) =>
|
||||||
|
string.Equals(phaseKey, "nomination", StringComparison.OrdinalIgnoreCase) || candidateCount > 0;
|
||||||
|
|
||||||
|
private static Dictionary<int, PublicCandidateClip> BuildCandidateClipLookup(IEnumerable<PublicCandidateClip> clips) =>
|
||||||
|
clips
|
||||||
|
.Where(clip => clip.CandidateId is not null)
|
||||||
|
.GroupBy(clip => clip.CandidateId!.Value)
|
||||||
|
.ToDictionary(
|
||||||
|
grouping => grouping.Key,
|
||||||
|
grouping => grouping
|
||||||
|
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
|
||||||
|
.First());
|
||||||
|
|
||||||
|
private static Dictionary<string, PublicCandidateClip> BuildCreatorClipLookup(IEnumerable<PublicCandidateClip> clips) =>
|
||||||
|
clips
|
||||||
|
.Where(clip => clip.CategoryId is not null && !string.IsNullOrWhiteSpace(clip.Creator))
|
||||||
|
.GroupBy(clip => BuildCandidateClipLookupKey(clip.CategoryId!.Value, clip.Creator))
|
||||||
|
.Where(grouping => !string.IsNullOrWhiteSpace(grouping.Key))
|
||||||
|
.ToDictionary(
|
||||||
|
grouping => grouping.Key,
|
||||||
|
grouping => grouping
|
||||||
|
.OrderByDescending(clip => clip.ReviewedAt ?? clip.CreatedAt)
|
||||||
|
.First());
|
||||||
|
|
||||||
|
private static PublicCandidateClip? ResolveCandidateClip(
|
||||||
|
Candidate candidate,
|
||||||
|
IReadOnlyDictionary<int, PublicCandidateClip> clipsByCandidateId,
|
||||||
|
IReadOnlyDictionary<string, PublicCandidateClip> clipsByCreatorKey)
|
||||||
|
{
|
||||||
|
if (clipsByCandidateId.TryGetValue(candidate.Id, out var directClip))
|
||||||
|
{
|
||||||
|
return directClip;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in BuildCandidateClipLookupKeys(candidate))
|
||||||
|
{
|
||||||
|
if (clipsByCreatorKey.TryGetValue(key, out var fallbackClip))
|
||||||
|
{
|
||||||
|
return fallbackClip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> BuildCandidateClipLookupKeys(Candidate candidate)
|
||||||
|
{
|
||||||
|
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.DisplayName);
|
||||||
|
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug);
|
||||||
|
yield return BuildCandidateClipLookupKey(candidate.CategoryId, candidate.ChannelSlug.TrimStart('@'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCandidateClipLookupKey(int categoryId, string value)
|
||||||
|
{
|
||||||
|
var key = NormalizeCandidateClipKey(value);
|
||||||
|
return string.IsNullOrWhiteSpace(key) ? string.Empty : $"{categoryId}:{key}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeCandidateClipKey(string value)
|
||||||
|
{
|
||||||
|
var normalizedCharacters = value
|
||||||
|
.Trim()
|
||||||
|
.TrimStart('@')
|
||||||
|
.ToLowerInvariant()
|
||||||
|
.Where(char.IsLetterOrDigit)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new string(normalizedCharacters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribePublicPhase(string phaseKey) =>
|
||||||
|
phaseKey switch
|
||||||
|
{
|
||||||
|
"nomination" => "nomination",
|
||||||
|
"voting" => "voting",
|
||||||
|
"review" => "review",
|
||||||
|
"show" => "show",
|
||||||
|
_ => "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapPublicEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/public");
|
||||||
|
|
||||||
|
group.MapGet("/overview", GetOverview)
|
||||||
|
.WithName("GetOverview")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/site-status", GetSiteStatus)
|
||||||
|
.WithName("GetSiteStatus")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/host-image", GetHostImage)
|
||||||
|
.WithName("GetPublicHostImage")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
|
||||||
|
.WithName("GetSeasonCategories")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/seasons/{year:int}/winners", GetWinnerArchive)
|
||||||
|
.WithName("GetWinnerArchive")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/seasons/{year:int}/sponsors", GetSponsors)
|
||||||
|
.WithName("GetPublicSponsors")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
||||||
|
.WithName("GetUserParticipation")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/nominations", CreateNomination)
|
||||||
|
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||||
|
.WithName("CreateNomination")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/votes", CreateVote)
|
||||||
|
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||||
|
.WithName("CreateVote")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/clips", CreateClip)
|
||||||
|
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||||
|
.WithName("CreateClip")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/showacts", CreateShowactApplication)
|
||||||
|
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||||
|
.WithName("CreateShowactApplication")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Year == year);
|
||||||
|
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null || !settings.SponsorsVisible)
|
||||||
|
{
|
||||||
|
return Results.Ok(new PublicSponsorsResponse(year, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
var sponsors = await db.Sponsors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == season.Id && item.IsVisible)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.Name)
|
||||||
|
.Select(item => new SponsorDto(
|
||||||
|
item.Id,
|
||||||
|
item.SeasonId,
|
||||||
|
item.Name,
|
||||||
|
item.WebsiteUrl,
|
||||||
|
item.LogoUrl,
|
||||||
|
item.Description,
|
||||||
|
item.Tier,
|
||||||
|
item.SortOrder,
|
||||||
|
item.IsVisible))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions ShowactJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateShowactApplication(
|
||||||
|
HttpContext context,
|
||||||
|
CreateShowactApplicationRequest request,
|
||||||
|
AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
||||||
|
if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new
|
||||||
|
{
|
||||||
|
message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage)
|
||||||
|
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||||
|
: settings.ShowactApplicationDisabledMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted);
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsBlankOrValidJsonObject(request.FieldResponsesJson))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var schema = ParseShowactSchema(settings.ShowactFormSchemaJson);
|
||||||
|
var hasDynamicForm = schema.Count > 0;
|
||||||
|
|
||||||
|
if (hasDynamicForm)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> responses;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson)
|
||||||
|
? new Dictionary<string, string>()
|
||||||
|
: JsonSerializer.Deserialize<Dictionary<string, string>>(request.FieldResponsesJson, ShowactJsonOptions) ?? new();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
MergeLegacyShowactFieldsIntoResponses(schema, responses, request);
|
||||||
|
|
||||||
|
var dynamicValidationError = ValidateShowactResponses(schema, responses);
|
||||||
|
if (dynamicValidationError is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = dynamicValidationError });
|
||||||
|
}
|
||||||
|
|
||||||
|
var artistNameField = schema.FirstOrDefault(f => f.IsArtistName);
|
||||||
|
var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt";
|
||||||
|
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||||
|
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||||
|
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||||
|
var description = NormalizePublicText(request.Description, 1000);
|
||||||
|
var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000);
|
||||||
|
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||||
|
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||||
|
var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions);
|
||||||
|
|
||||||
|
var metadata = RequestMetadataReader.Read(context);
|
||||||
|
var application = new ShowactApplication
|
||||||
|
{
|
||||||
|
SeasonId = season.Id,
|
||||||
|
ArtistName = artistName[..Math.Min(artistName.Length, 120)],
|
||||||
|
ContactEmail = contactEmail,
|
||||||
|
ContactDiscord = contactDiscord,
|
||||||
|
PlatformUrl = platformUrl,
|
||||||
|
PerformanceType = performanceType,
|
||||||
|
Description = description,
|
||||||
|
TechnicalNotes = technicalNotes,
|
||||||
|
ReferenceUrl = referenceUrl,
|
||||||
|
FieldResponsesJson = fieldResponsesJson,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedFromIp = metadata.ClientIp,
|
||||||
|
UserAgent = metadata.UserAgent,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.ShowactApplications.Add(application);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Legacy fixed-fields path
|
||||||
|
var artistName = NormalizePublicText(request.ArtistName, 120);
|
||||||
|
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
|
||||||
|
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
|
||||||
|
var performanceType = NormalizePublicText(request.PerformanceType, 80);
|
||||||
|
var description = NormalizePublicText(request.Description, 1000);
|
||||||
|
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
|
||||||
|
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(artistName))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsBlankOrValidEmail(contactEmail))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = RequestMetadataReader.Read(context);
|
||||||
|
var application = new ShowactApplication
|
||||||
|
{
|
||||||
|
SeasonId = season.Id,
|
||||||
|
ArtistName = artistName,
|
||||||
|
ContactEmail = contactEmail,
|
||||||
|
ContactDiscord = contactDiscord,
|
||||||
|
PlatformUrl = platformUrl,
|
||||||
|
PerformanceType = performanceType,
|
||||||
|
Description = description,
|
||||||
|
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
|
||||||
|
ReferenceUrl = referenceUrl,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedFromIp = metadata.ClientIp,
|
||||||
|
UserAgent = metadata.UserAgent,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ShowactApplications.Add(application);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, applicationId = application.Id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ShowactFieldDefinition
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
|
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||||
|
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||||
|
[JsonPropertyName("required")] public bool Required { get; set; }
|
||||||
|
[JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; }
|
||||||
|
[JsonPropertyName("maxLength")] public int MaxLength { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePublicText(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
var trimmed = (value ?? string.Empty).Trim();
|
||||||
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBlankOrHttpUrl(string value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||||
|
|
||||||
|
private static bool IsBlankOrValidEmail(string value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value);
|
||||||
|
|
||||||
|
private static bool IsBlankOrValidJsonObject(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(value);
|
||||||
|
return document.RootElement.ValueKind == JsonValueKind.Object;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ShowactFieldDefinition> ParseShowactSchema(string? schemaJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]")
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<ShowactFieldDefinition>>(schemaJson, ShowactJsonOptions)?
|
||||||
|
.Where(field => !string.IsNullOrWhiteSpace(field.Id))
|
||||||
|
.ToList() ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateShowactResponses(
|
||||||
|
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||||
|
IDictionary<string, string> responses)
|
||||||
|
{
|
||||||
|
foreach (var field in schema)
|
||||||
|
{
|
||||||
|
var value = responses.TryGetValue(field.Id, out var rawValue)
|
||||||
|
? NormalizePublicText(rawValue, ResolveShowactMaxLength(field))
|
||||||
|
: string.Empty;
|
||||||
|
responses[field.Id] = value;
|
||||||
|
|
||||||
|
if (field.Required && string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"Bitte bestaetige: {field.Label}"
|
||||||
|
: $"{field.Label} ist erforderlich.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value))
|
||||||
|
{
|
||||||
|
return $"{field.Label} muss eine gueltige E-Mail-Adresse sein.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value))
|
||||||
|
{
|
||||||
|
return $"{field.Label} muss ein gueltiger http(s)-Link sein.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveShowactMaxLength(ShowactFieldDefinition field)
|
||||||
|
{
|
||||||
|
if (field.MaxLength > 0)
|
||||||
|
{
|
||||||
|
return Math.Min(field.MaxLength, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeLegacyShowactFieldsIntoResponses(
|
||||||
|
IReadOnlyCollection<ShowactFieldDefinition> schema,
|
||||||
|
IDictionary<string, string> responses,
|
||||||
|
CreateShowactApplicationRequest request)
|
||||||
|
{
|
||||||
|
var artistField = schema.FirstOrDefault(field => field.IsArtistName);
|
||||||
|
MergeResponseValue(artistField, request.ArtistName, responses, 120);
|
||||||
|
|
||||||
|
var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase));
|
||||||
|
MergeResponseValue(emailField, request.ContactEmail, responses, 180);
|
||||||
|
|
||||||
|
var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord"));
|
||||||
|
MergeResponseValue(discordField, request.ContactDiscord, responses, 120);
|
||||||
|
|
||||||
|
var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel"));
|
||||||
|
MergeResponseValue(platformField, request.PlatformUrl, responses, 500);
|
||||||
|
|
||||||
|
var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference"));
|
||||||
|
MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500);
|
||||||
|
|
||||||
|
var performanceField = schema.FirstOrDefault(field =>
|
||||||
|
ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles")
|
||||||
|
|| ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben"));
|
||||||
|
MergeResponseValue(performanceField, request.PerformanceType, responses, 80);
|
||||||
|
|
||||||
|
var descriptionField = schema.FirstOrDefault(field =>
|
||||||
|
string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (ContainsAnyId(field, "description", "beschreibung", "show_description")
|
||||||
|
|| ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen")));
|
||||||
|
MergeResponseValue(descriptionField, request.Description, responses, 1000);
|
||||||
|
|
||||||
|
var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing"));
|
||||||
|
MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeResponseValue(
|
||||||
|
ShowactFieldDefinition? field,
|
||||||
|
string? requestValue,
|
||||||
|
IDictionary<string, string> responses,
|
||||||
|
int maxLength)
|
||||||
|
{
|
||||||
|
if (field is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizePublicText(requestValue, maxLength);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
responses[field.Id] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles)
|
||||||
|
{
|
||||||
|
var haystack = $"{field.Id} {field.Label}".ToLowerInvariant();
|
||||||
|
return needles.Any(haystack.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles)
|
||||||
|
{
|
||||||
|
var haystack = field.Id.ToLowerInvariant();
|
||||||
|
return needles.Any(haystack.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles)
|
||||||
|
{
|
||||||
|
var haystack = field.Label.ToLowerInvariant();
|
||||||
|
return needles.Any(haystack.Contains);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> CreateNomination(
|
||||||
|
HttpContext context,
|
||||||
|
CreateNominationRequest? request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
|
IRiskFlagService riskFlagService,
|
||||||
|
IRiskRuleService riskRuleService,
|
||||||
|
NominationEnrichmentService nominationEnrichmentService)
|
||||||
|
{
|
||||||
|
var validationError = ValidateCreateNominationRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
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 }))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Legacy nominee names must stay below 120 characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submittedNominations.Any(item => item.StreamUrl.Length > 300))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Stream links must stay below 300 characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctStreamUrls = submittedNominations
|
||||||
|
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (distinctStreamUrls.Length != submittedNominations.Length)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Duplicate stream links are not allowed inside one category." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var invalidStreamUrl = submittedNominations
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||||
|
.Select(item => item.StreamUrl)
|
||||||
|
.FirstOrDefault(item => !TryNormalizeExternalUrl(item, out _));
|
||||||
|
|
||||||
|
if (invalidStreamUrl is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
var linkBlacklist = NominationLinkBlacklistSettings.Read(settings);
|
||||||
|
var blacklistedStreamUrl = submittedNominations
|
||||||
|
.Select(item => item.StreamUrl)
|
||||||
|
.FirstOrDefault(item => NominationLinkBlacklistSettings.IsBlocked(item, linkBlacklist));
|
||||||
|
|
||||||
|
if (blacklistedStreamUrl is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return nominationSeasonResolution.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
|
||||||
|
if (submitterIdResult.Result is not null)
|
||||||
|
{
|
||||||
|
return submitterIdResult.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var submitterId = submitterIdResult.SubmitterId!;
|
||||||
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
|
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||||
|
item.SeasonId == season.Id
|
||||||
|
&& item.CategoryGroupName == categoryGroupName
|
||||||
|
&& item.SubmittedByTwitchId == submitterId
|
||||||
|
&& item.Status == "pending");
|
||||||
|
|
||||||
|
var records = submittedNominations.Select(nomination => new Nomination
|
||||||
|
{
|
||||||
|
SeasonId = season.Id,
|
||||||
|
CategoryId = null,
|
||||||
|
CategoryGroupName = categoryGroupName,
|
||||||
|
SubmittedByTwitchId = submitterId,
|
||||||
|
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||||
|
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||||
|
Status = "pending",
|
||||||
|
ReviewNote = $"Stream-Link: {nomination.StreamUrl}",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.Nominations.AddRangeAsync(records);
|
||||||
|
|
||||||
|
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||||
|
var rapidNominationBurstRule = await riskRuleService.GetRuleAsync("rapid_nomination_burst", context.RequestAborted);
|
||||||
|
var recentNominationVolume = await db.Nominations.CountAsync(item =>
|
||||||
|
item.SubmittedByTwitchId == submitterId
|
||||||
|
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidNominationBurstRule.WindowMinutes));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
var reviewLink = new
|
||||||
|
{
|
||||||
|
label = "Review-Fälle öffnen",
|
||||||
|
entityType = "nomination",
|
||||||
|
entityId = string.Join(",", records.Select(item => item.Id)),
|
||||||
|
to = $"/admin/reviews?query={Uri.EscapeDataString(submitterId)}",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
season.Id,
|
||||||
|
submitterId,
|
||||||
|
"nomination",
|
||||||
|
"resubmitted_nomination",
|
||||||
|
resubmittedNominationRule.Severity,
|
||||||
|
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
||||||
|
requestMetadata,
|
||||||
|
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||||
|
context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
season.Id,
|
||||||
|
submitterId,
|
||||||
|
"nomination",
|
||||||
|
"rapid_nomination_burst",
|
||||||
|
rapidNominationBurstRule.Severity,
|
||||||
|
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
|
||||||
|
requestMetadata,
|
||||||
|
new { recentNominationVolume, threshold = rapidNominationBurstRule.Threshold, windowMinutes = rapidNominationBurstRule.WindowMinutes, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||||
|
context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||||
|
|
||||||
|
private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request)
|
||||||
|
{
|
||||||
|
if (request.Nominations is { Length: > 0 })
|
||||||
|
{
|
||||||
|
return request.Nominations
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var name = item.Name?.Trim() ?? string.Empty;
|
||||||
|
var streamUrl = item.StreamUrl?.Trim() ?? string.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
|
||||||
|
{
|
||||||
|
streamUrl = normalizedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SubmittedNomination(string.IsNullOrWhiteSpace(name) ? null : name, streamUrl);
|
||||||
|
})
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (request.Nominees ?? [])
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var streamUrl = item.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
|
||||||
|
{
|
||||||
|
streamUrl = normalizedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SubmittedNomination(null, streamUrl);
|
||||||
|
})
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeNominationUrlForCompare(string value) =>
|
||||||
|
value.Trim().TrimEnd('/').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
|
||||||
|
{
|
||||||
|
var configuredLimit = groupCategories
|
||||||
|
.Select(item => item.MaxNomineesPerUser)
|
||||||
|
.Where(value => value > 0)
|
||||||
|
.DefaultIfEmpty(3)
|
||||||
|
.Max();
|
||||||
|
|
||||||
|
return Math.Clamp(configuredLimit, 1, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ResolveCategoryGroupNameAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int seasonId,
|
||||||
|
CreateNominationRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var categoryGroupName = request.CategoryGroupName?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(categoryGroupName))
|
||||||
|
{
|
||||||
|
return await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
|
||||||
|
.Select(item => item.GroupName)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.CategoryId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.Categories
|
||||||
|
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
|
||||||
|
.Select(item => item.GroupName)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetOverview(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var siteSettings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
|
||||||
|
.ThenInclude(category => category.Candidates)
|
||||||
|
.OrderByDescending(item => item.Year)
|
||||||
|
.FirstOrDefaultAsync(item => item.IsCurrent);
|
||||||
|
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteSettings is null)
|
||||||
|
{
|
||||||
|
return Results.Problem("Site settings are missing.");
|
||||||
|
}
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today);
|
||||||
|
|
||||||
|
var latestPublishedWinnerYear = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(result => result.Season.WinnersPublishedAt != null)
|
||||||
|
.Select(result => (int?)result.Season.Year)
|
||||||
|
.MaxAsync();
|
||||||
|
|
||||||
|
var winnerPreviewRows = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(result => result.Season)
|
||||||
|
.Include(result => result.Candidate)
|
||||||
|
.Where(result => result.Season.WinnersPublishedAt != null
|
||||||
|
&& latestPublishedWinnerYear != null
|
||||||
|
&& result.Season.Year == latestPublishedWinnerYear.Value)
|
||||||
|
.OrderByDescending(result => result.Season.Year)
|
||||||
|
.ThenBy(result => result.CategoryName)
|
||||||
|
.Take(8)
|
||||||
|
.Select(result => new
|
||||||
|
{
|
||||||
|
Year = result.Season.Year,
|
||||||
|
CategoryGroup = result.Category.GroupName,
|
||||||
|
result.CategoryName,
|
||||||
|
WinnerName = result.Candidate.DisplayName,
|
||||||
|
WinnerSlug = result.Candidate.ChannelSlug,
|
||||||
|
WinnerPlatform = result.Candidate.Platform,
|
||||||
|
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||||
|
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||||
|
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||||
|
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||||
|
})
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var winnerPreviewItems = winnerPreviewRows
|
||||||
|
.Select(result => new WinnerPreviewDto(
|
||||||
|
result.Year,
|
||||||
|
result.CategoryGroup,
|
||||||
|
result.CategoryName,
|
||||||
|
result.WinnerName,
|
||||||
|
result.WinnerSlug,
|
||||||
|
result.WinnerPlatform,
|
||||||
|
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||||
|
result.ClipUrl,
|
||||||
|
result.ClipTitle,
|
||||||
|
result.ClipPlatform,
|
||||||
|
result.ClipEmbedStatus))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var archiveYearRows = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(result => result.Season.WinnersPublishedAt != null
|
||||||
|
&& latestPublishedWinnerYear != null
|
||||||
|
&& result.Season.Year < latestPublishedWinnerYear.Value)
|
||||||
|
.GroupBy(result => result.Season.Year)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Year = group.Key,
|
||||||
|
WinnerCount = group.Count(),
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.Year)
|
||||||
|
.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
|
||||||
|
.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();
|
||||||
|
|
||||||
|
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
|
var publicCategories = season.Categories
|
||||||
|
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||||
|
.ToArray();
|
||||||
|
var featuredCategories = publicCategories
|
||||||
|
.GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var ordered = group
|
||||||
|
.OrderBy(category => category.SortOrder)
|
||||||
|
.ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var first = ordered[0];
|
||||||
|
var groupDescription = ordered
|
||||||
|
.Select(category => category.Description?.Trim())
|
||||||
|
.FirstOrDefault(description => !string.IsNullOrWhiteSpace(description))
|
||||||
|
?? string.Empty;
|
||||||
|
var maxNomineesPerUser = ordered
|
||||||
|
.Select(category => category.MaxNomineesPerUser)
|
||||||
|
.Where(value => value > 0)
|
||||||
|
.DefaultIfEmpty(3)
|
||||||
|
.Max();
|
||||||
|
|
||||||
|
return new FeaturedCategoryDto(
|
||||||
|
first.Id,
|
||||||
|
first.GroupName,
|
||||||
|
first.GroupName,
|
||||||
|
groupDescription,
|
||||||
|
maxNomineesPerUser);
|
||||||
|
})
|
||||||
|
.OrderBy(category => publicCategories
|
||||||
|
.Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Min(item => item.SortOrder))
|
||||||
|
.ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var response = new OverviewResponse(
|
||||||
|
season.Id,
|
||||||
|
season.Year,
|
||||||
|
season.Name,
|
||||||
|
season.ShowDate,
|
||||||
|
season.ShowStartsAt,
|
||||||
|
season.CurrentPhase,
|
||||||
|
season.IsCommunityOnly,
|
||||||
|
"Twitch",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, SeasonMappings.ResolveTimelineState("nomination", phaseKey)),
|
||||||
|
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, SeasonMappings.ResolveTimelineState("voting", 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)),
|
||||||
|
},
|
||||||
|
featuredCategories,
|
||||||
|
winnerPreviewItems,
|
||||||
|
archiveYears,
|
||||||
|
new PublicSiteContentDto(
|
||||||
|
siteSettings.HostDisplayName,
|
||||||
|
siteSettings.HostTagline,
|
||||||
|
siteSettings.HostArtistName,
|
||||||
|
AdminSiteSettingsEndpoints.BuildHostImageUrl(siteSettings),
|
||||||
|
siteSettings.NewsletterUrl,
|
||||||
|
siteSettings.ShareXUrl,
|
||||||
|
siteSettings.ShareDiscordUrl,
|
||||||
|
siteSettings.PrivacyEmail,
|
||||||
|
siteSettings.PrivacyPolicyContent,
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.AwardsSectionTitle),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.AwardsSectionDescription),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.SubcategoriesSectionTitle),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.SubcategoriesSectionDescription),
|
||||||
|
new PublicStreamBannerContentDto(
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerEyebrow),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerTitle),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerText),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerLiveButtonLabel),
|
||||||
|
SeasonMappings.NormalizeSeasonStreamUrl(siteSettings.StreamBannerLiveButtonUrl),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerLockedButtonLabel),
|
||||||
|
siteSettings.StreamBannerUseCompletedContent,
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedEyebrow),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedTitle),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedText),
|
||||||
|
SeasonMappings.NormalizePlainTextContent(siteSettings.StreamBannerCompletedButtonLabel),
|
||||||
|
SeasonMappings.NormalizeSeasonStreamUrl(siteSettings.StreamBannerCompletedButtonUrl)),
|
||||||
|
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||||
|
SeasonMappings.BuildFooterLinks(siteSettings)),
|
||||||
|
new PublicFeatureFlagsDto(
|
||||||
|
siteSettings.ClipSubmissionsEnabled,
|
||||||
|
siteSettings.ClipReviewEnabled,
|
||||||
|
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
||||||
|
? "Clip-Einreichungen sind aktuell geschlossen."
|
||||||
|
: siteSettings.ClipSubmissionDisabledMessage,
|
||||||
|
showactApplicationsOpenNow,
|
||||||
|
siteSettings.ShowactApplicationStartsAt,
|
||||||
|
siteSettings.ShowactApplicationEndsAt,
|
||||||
|
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
|
||||||
|
? "Showact-Bewerbungen sind aktuell geschlossen."
|
||||||
|
: siteSettings.ShowactApplicationDisabledMessage,
|
||||||
|
siteSettings.SponsorsVisible,
|
||||||
|
siteSettings.ShowactFormSchemaJson ?? "[]"),
|
||||||
|
SeasonMappings.ReadFaqItems(siteSettings));
|
||||||
|
|
||||||
|
return Results.Ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetSeasonCategories(int year, AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
|
||||||
|
.ThenInclude(category => category.Candidates.OrderBy(candidate => candidate.DisplayName))
|
||||||
|
.FirstOrDefaultAsync(item => item.Year == year);
|
||||||
|
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
|
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories);
|
||||||
|
var publicCategories = season.Categories
|
||||||
|
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates))
|
||||||
|
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||||
|
.ToArray();
|
||||||
|
var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray();
|
||||||
|
var approvedClips = await db.ClipSubmissions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.SeasonId == season.Id
|
||||||
|
&& item.Status == "approved"
|
||||||
|
&& item.CategoryId != null
|
||||||
|
&& publicCategoryIds.Contains(item.CategoryId.Value))
|
||||||
|
.Select(item => new PublicCandidateClip(
|
||||||
|
item.CategoryId,
|
||||||
|
item.CandidateId,
|
||||||
|
item.Creator,
|
||||||
|
item.ClipUrl,
|
||||||
|
item.Title,
|
||||||
|
item.Platform,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.ReviewedAt))
|
||||||
|
.ToArrayAsync();
|
||||||
|
var clipsByCandidateId = BuildCandidateClipLookup(approvedClips);
|
||||||
|
var clipsByCreatorKey = BuildCreatorClipLookup(approvedClips);
|
||||||
|
|
||||||
|
return Results.Ok(new SeasonCategoriesResponse(
|
||||||
|
season.Id,
|
||||||
|
season.Year,
|
||||||
|
publicCategories.Select(category => new PublicCategoryDetailDto(
|
||||||
|
category.Id,
|
||||||
|
category.Name,
|
||||||
|
category.GroupName,
|
||||||
|
category.Description,
|
||||||
|
category.MaxNomineesPerUser,
|
||||||
|
category.Candidates
|
||||||
|
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(candidate =>
|
||||||
|
{
|
||||||
|
var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey);
|
||||||
|
var candidateClipUrl = string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)
|
||||||
|
|| string.Equals(candidate.ClipEmbedStatus, "blocked", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? null
|
||||||
|
: candidate.ClipCompilationUrl.Trim();
|
||||||
|
var clipUrl = candidateClipUrl ?? clip?.ClipUrl;
|
||||||
|
var clipTitle = candidateClipUrl is not null
|
||||||
|
? string.IsNullOrWhiteSpace(candidate.ClipCompilationTitle) ? "Highlight-Clip ansehen" : candidate.ClipCompilationTitle.Trim()
|
||||||
|
: clip?.Title;
|
||||||
|
var clipPlatform = candidateClipUrl is not null
|
||||||
|
? string.IsNullOrWhiteSpace(candidate.ClipCompilationPlatform) ? candidate.Platform : candidate.ClipCompilationPlatform.Trim()
|
||||||
|
: clip?.Platform;
|
||||||
|
var clipEmbedStatus = candidateClipUrl is not null
|
||||||
|
? candidate.ClipEmbedStatus
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new CandidateSummaryDto(
|
||||||
|
candidate.Id,
|
||||||
|
candidate.DisplayName,
|
||||||
|
candidate.ChannelSlug,
|
||||||
|
SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug),
|
||||||
|
candidate.Platform,
|
||||||
|
clipUrl,
|
||||||
|
clipTitle,
|
||||||
|
clipPlatform,
|
||||||
|
clipEmbedStatus);
|
||||||
|
}).ToArray()))
|
||||||
|
.ToArray()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetSiteStatus(AwardsDbContext db, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return Results.Ok(new PublicSiteStatusResponse(
|
||||||
|
IsDemoLoginEnabled(configuration),
|
||||||
|
false,
|
||||||
|
"Sternenpause",
|
||||||
|
"Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new PublicSiteStatusResponse(
|
||||||
|
ResolveDemoLoginEnabled(settings, configuration),
|
||||||
|
settings.MaintenanceModeEnabled,
|
||||||
|
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? "Sternenpause" : settings.MaintenanceTitle,
|
||||||
|
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||||
|
? "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."
|
||||||
|
: settings.MaintenanceMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase;
|
||||||
|
if (!usesDatabaseDemo)
|
||||||
|
{
|
||||||
|
return IsDemoLoginEnabled(configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings.DemoLoginEnabled && HasDatabaseDemoCredentials(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"]
|
||||||
|
?? configuration["DemoAdmin:Enabled"];
|
||||||
|
|
||||||
|
return bool.TryParse(rawValue, out var enabled) && enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasDatabaseDemoCredentials(Backend.Domain.SiteSettings settings) =>
|
||||||
|
!string.IsNullOrWhiteSpace(settings.DemoLoginEmail)
|
||||||
|
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
||||||
|
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> GetUserParticipation(
|
||||||
|
HttpContext context,
|
||||||
|
int year,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Year == year);
|
||||||
|
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nominations = await db.Nominations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId)
|
||||||
|
.OrderBy(item => item.CategoryId)
|
||||||
|
.ThenBy(item => item.Id)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.CategoryId,
|
||||||
|
item.CategoryGroupName,
|
||||||
|
item.Status,
|
||||||
|
Nominee = item.CandidateId != null
|
||||||
|
? item.Candidate!.DisplayName
|
||||||
|
: item.CandidateText ?? item.StreamUrl,
|
||||||
|
})
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var groupedNominations = nominations
|
||||||
|
.Where(item => item.Status != "rejected" && item.Status != "superseded")
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.Nominee))
|
||||||
|
.GroupBy(item => new { item.CategoryId, item.CategoryGroupName })
|
||||||
|
.Select(group => new UserNominationStateDto(
|
||||||
|
group.Key.CategoryId,
|
||||||
|
group.Key.CategoryGroupName,
|
||||||
|
group.Select(item => item.Nominee!)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray()))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var votes = await db.VoteEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Ballot.SeasonId == season.Id && item.Ballot.SubmittedByTwitchId == session.TwitchUserId)
|
||||||
|
.OrderBy(item => item.CategoryId)
|
||||||
|
.Select(item => new UserVoteStateDto(item.CategoryId, item.CandidateId))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var clips = await db.ClipSubmissions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == season.Id && item.SubmittedByTwitchId == session.TwitchUserId)
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(12)
|
||||||
|
.Select(item => new UserClipSubmissionStateDto(
|
||||||
|
item.Id,
|
||||||
|
item.CategoryId,
|
||||||
|
item.ClipUrl,
|
||||||
|
item.Title,
|
||||||
|
item.Creator,
|
||||||
|
item.Platform,
|
||||||
|
item.Status,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.ReviewNote,
|
||||||
|
item.ReviewedAt))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new UserParticipationResponse(
|
||||||
|
season.Id,
|
||||||
|
season.Year,
|
||||||
|
groupedNominations,
|
||||||
|
votes,
|
||||||
|
clips));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
private static async Task<IResult> CreateVote(
|
||||||
|
HttpContext context,
|
||||||
|
CreateVoteRequest? request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService,
|
||||||
|
IRiskFlagService riskFlagService,
|
||||||
|
IRiskRuleService riskRuleService)
|
||||||
|
{
|
||||||
|
var validationError = ValidateCreateVoteRequest(request);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = request!;
|
||||||
|
var distinctCategoryCount = validatedRequest.Entries
|
||||||
|
.Select(item => item.CategoryId)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
|
||||||
|
if (distinctCategoryCount != validatedRequest.Entries.Length)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var season = await db.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == validatedRequest.SeasonId);
|
||||||
|
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
|
||||||
|
if (voteSeasonResolution.Result is not null)
|
||||||
|
{
|
||||||
|
return voteSeasonResolution.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
|
||||||
|
if (submitterIdResult.Result is not null)
|
||||||
|
{
|
||||||
|
return submitterIdResult.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var submitterId = submitterIdResult.SubmitterId!;
|
||||||
|
var requestMetadata = RequestMetadataReader.Read(context);
|
||||||
|
var candidateIds = validatedRequest.Entries.Select(item => item.CandidateId).Distinct().ToArray();
|
||||||
|
var validCandidates = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == validatedRequest.SeasonId && candidateIds.Contains(item.Id))
|
||||||
|
.Select(item => new { item.Id, item.CategoryId })
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
if (validCandidates.Length != candidateIds.Length)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => 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." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var ballot = await db.VoteBallots
|
||||||
|
.Include(item => item.Entries)
|
||||||
|
.FirstOrDefaultAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId);
|
||||||
|
|
||||||
|
var isResubmission = ballot is not null;
|
||||||
|
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
|
||||||
|
|
||||||
|
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
|
||||||
|
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
|
||||||
|
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
|
||||||
|
item.SubmittedByTwitchId == submitterId
|
||||||
|
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error))
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
ballot = await db.VoteBallots
|
||||||
|
.Include(item => item.Entries)
|
||||||
|
.FirstAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
|
||||||
|
isResubmission = true;
|
||||||
|
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ballotLink = new
|
||||||
|
{
|
||||||
|
label = "Voting-Analytics öffnen",
|
||||||
|
entityType = "vote",
|
||||||
|
entityId = ballot.Id.ToString(),
|
||||||
|
to = $"/admin/analytics?query={Uri.EscapeDataString(submitterId)}",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isResubmission && resubmittedBallotRule.Enabled)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
validatedRequest.SeasonId,
|
||||||
|
submitterId,
|
||||||
|
"vote",
|
||||||
|
"resubmitted_ballot",
|
||||||
|
resubmittedBallotRule.Severity,
|
||||||
|
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
|
||||||
|
requestMetadata,
|
||||||
|
new { ballotId = ballot.Id, entryCount = validatedRequest.Entries.Length, entityLinks = new[] { ballotLink } },
|
||||||
|
context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
|
||||||
|
{
|
||||||
|
await riskFlagService.AddIfMissingAsync(
|
||||||
|
validatedRequest.SeasonId,
|
||||||
|
submitterId,
|
||||||
|
"vote",
|
||||||
|
"rapid_vote_updates",
|
||||||
|
rapidVoteUpdatesRule.Severity,
|
||||||
|
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
|
||||||
|
requestMetadata,
|
||||||
|
new { ballotId = ballot.Id, recentVoteSubmissions, threshold = rapidVoteUpdatesRule.Threshold, windowMinutes = rapidVoteUpdatesRule.WindowMinutes, entityLinks = new[] { ballotLink } },
|
||||||
|
context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<VoteBallot> ApplyVoteEntriesAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
VoteBallot? ballot,
|
||||||
|
int seasonId,
|
||||||
|
string submitterId,
|
||||||
|
VoteEntryRequest[] entries,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ballot is null)
|
||||||
|
{
|
||||||
|
ballot = new VoteBallot
|
||||||
|
{
|
||||||
|
SeasonId = seasonId,
|
||||||
|
SubmittedByTwitchId = submitterId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.VoteBallots.AddAsync(ballot, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||||
|
ballot.Entries.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||||
|
ballot.Status = "submitted";
|
||||||
|
ballot.Entries = entries.Select(entry => new VoteEntry
|
||||||
|
{
|
||||||
|
CategoryId = entry.CategoryId,
|
||||||
|
CandidateId = entry.CandidateId,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return ballot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUniqueVoteBallotViolation(DbUpdateException error) =>
|
||||||
|
error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class PublicEndpoints
|
||||||
|
{
|
||||||
|
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
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Year == year)
|
||||||
|
.Select(item => new { item.Id, item.Year, item.WinnersPublishedAt })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (season is null)
|
||||||
|
{
|
||||||
|
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season.WinnersPublishedAt is null)
|
||||||
|
{
|
||||||
|
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestPublishedWinnerYear == season.Year)
|
||||||
|
{
|
||||||
|
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
var winnerRows = await db.Results
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(result => result.Candidate)
|
||||||
|
.Where(result => result.SeasonId == season.Id)
|
||||||
|
.OrderBy(result => result.CategoryName)
|
||||||
|
.Select(result => new
|
||||||
|
{
|
||||||
|
CategoryGroup = result.Category.GroupName,
|
||||||
|
result.CategoryName,
|
||||||
|
WinnerName = result.Candidate.DisplayName,
|
||||||
|
WinnerSlug = result.Candidate.ChannelSlug,
|
||||||
|
WinnerPlatform = result.Candidate.Platform,
|
||||||
|
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||||
|
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||||
|
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||||
|
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||||
|
})
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var items = winnerRows
|
||||||
|
.Select(result => new WinnerArchiveItemDto(
|
||||||
|
result.CategoryGroup,
|
||||||
|
result.CategoryName,
|
||||||
|
result.WinnerName,
|
||||||
|
result.WinnerSlug,
|
||||||
|
result.WinnerPlatform,
|
||||||
|
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||||
|
result.ClipUrl,
|
||||||
|
result.ClipTitle,
|
||||||
|
result.ClipPlatform,
|
||||||
|
result.ClipEmbedStatus))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Results.Ok(new WinnerArchiveResponse(year, items));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static class SystemEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapSystemEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" }))
|
||||||
|
.WithName("GetHealth")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
app.MapGet("/api/health/database", async (AwardsDbContext db, IConfiguration configuration) =>
|
||||||
|
{
|
||||||
|
var source = configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var canConnect = await db.Database.CanConnectAsync();
|
||||||
|
var pendingMigrations = canConnect
|
||||||
|
? await db.Database.GetPendingMigrationsAsync()
|
||||||
|
: Array.Empty<string>();
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
provider = "postgres",
|
||||||
|
canConnect,
|
||||||
|
pendingMigrations,
|
||||||
|
configuredConnection = new { source },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
provider = "postgres",
|
||||||
|
canConnect = false,
|
||||||
|
pendingMigrations = Array.Empty<string>(),
|
||||||
|
configuredConnection = new { source },
|
||||||
|
error = exception.Message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WithName("GetDatabaseHealth")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Threading.RateLimiting;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Configuration;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Repositories;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Backend.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddApplicationServices(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
services.AddProblemDetails();
|
||||||
|
services.AddEndpointsApiExplorer();
|
||||||
|
services.AddSwaggerGen();
|
||||||
|
|
||||||
|
services.Configure<FrontendOptions>(configuration.GetSection(FrontendOptions.SectionName));
|
||||||
|
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
||||||
|
services.AddMemoryCache();
|
||||||
|
services.AddHttpClient();
|
||||||
|
services.AddHttpClient("TwitchTracker", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(4);
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
|
||||||
|
});
|
||||||
|
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
||||||
|
|
||||||
|
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"No PostgreSQL connection string configured. Set VTSA_POSTGRES or ConnectionStrings:Postgres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(ApplicationDefaults.FrontendCorsPolicy, policy =>
|
||||||
|
{
|
||||||
|
policy.WithOrigins(allowedOrigins)
|
||||||
|
.WithHeaders("Authorization", "Content-Type")
|
||||||
|
.WithMethods(HttpMethods.Get, HttpMethods.Post, HttpMethods.Put, HttpMethods.Delete);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.OnRejected = async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.ContentType = "application/json";
|
||||||
|
await context.HttpContext.Response.WriteAsJsonAsync(
|
||||||
|
new { message = "Zu viele Anfragen. Bitte kurz warten und erneut versuchen." },
|
||||||
|
cancellationToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
options.AddPolicy(ApplicationDefaults.AuthRateLimitPolicy, context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey: BuildRateLimitPartitionKey(context, "auth"),
|
||||||
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 5,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
options.AddPolicy(ApplicationDefaults.PublicWriteRateLimitPolicy, context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey: BuildRateLimitPartitionKey(context, "public-write"),
|
||||||
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 20,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddDbContext<AwardsDbContext>(options => options.UseNpgsql(connectionString));
|
||||||
|
|
||||||
|
services.AddScoped<IUserSessionRepository, UserSessionRepository>();
|
||||||
|
services.AddScoped<IRiskFlagRepository, RiskFlagRepository>();
|
||||||
|
services.AddScoped<IAdminAuditRepository, AdminAuditRepository>();
|
||||||
|
services.AddScoped<IUserSessionService, UserSessionService>();
|
||||||
|
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
||||||
|
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
||||||
|
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
||||||
|
services.AddScoped<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
|
||||||
|
services.AddScoped<NominationTrackingReviewService>();
|
||||||
|
services.AddScoped<NominationEnrichmentService>();
|
||||||
|
services.AddScoped<AdminSessionFilter>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildRateLimitPartitionKey(HttpContext context, string policyName)
|
||||||
|
{
|
||||||
|
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
|
||||||
|
var route = context.Request.Path.Value ?? "/";
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{policyName}:{ipAddress}:{route}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ResolveAllowedOrigins(IConfiguration configuration, IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
var frontendOptions = configuration.GetSection(FrontendOptions.SectionName).Get<FrontendOptions>();
|
||||||
|
var configuredOrigins = frontendOptions?.AllowedOrigins
|
||||||
|
.Select(NormalizeCorsOrigin)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray() ?? [];
|
||||||
|
|
||||||
|
if (configuredOrigins.Length > 0)
|
||||||
|
{
|
||||||
|
return configuredOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
return ApplicationDefaults.FrontendOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Frontend:AllowedOrigins must be configured in non-development environments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeCorsOrigin(string origin)
|
||||||
|
{
|
||||||
|
var trimmedOrigin = origin.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmedOrigin) || trimmedOrigin.Contains('*', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("CORS origins must be explicit http(s) origins. Wildcards are not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(trimmedOrigin, UriKind.Absolute, out var uri)
|
||||||
|
|| uri.Scheme is not ("http" or "https")
|
||||||
|
|| string.IsNullOrWhiteSpace(uri.Host))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Invalid CORS origin configured: {trimmedOrigin}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.GetLeftPart(UriPartial.Authority);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Endpoints;
|
||||||
|
using Backend.Security;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Extensions;
|
||||||
|
|
||||||
|
public static class WebApplicationExtensions
|
||||||
|
{
|
||||||
|
public static void UseApplicationPipeline(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseCors(ApplicationDefaults.FrontendCorsPolicy);
|
||||||
|
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task InitializeDatabaseAsync(this WebApplication app)
|
||||||
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AwardsDbContext>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("DatabaseInitialization");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await TeamAccountBootstrapper.EnsureAsync(db, app.Configuration);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
logger.LogError(error, "Database initialization failed. Check the PostgreSQL connection, migrations, and startup configuration.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MapApplicationEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapSystemEndpoints();
|
||||||
|
app.MapAuthEndpoints();
|
||||||
|
app.MapPublicEndpoints();
|
||||||
|
app.MapAdminEndpoints();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClipReviewWorkflow : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||||
|
DROP COLUMN IF EXISTS "ReviewNote";
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||||
|
DROP COLUMN IF EXISTS "ReviewedByTwitchId";
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS "ClipSubmissions"
|
||||||
|
DROP COLUMN IF EXISTS "ReviewedAt";
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNominationReviewWorkflow : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Nominations_SeasonId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ReviewNote",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ReviewedAt",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ReviewedByTwitchId",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "Nominations",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Nominations",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" },
|
||||||
|
values: new object[] { null, null, null, "pending" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Nominations",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
columns: new[] { "ReviewNote", "ReviewedAt", "ReviewedByTwitchId", "Status" },
|
||||||
|
values: new object[] { null, null, null, "pending" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Nominations_SeasonId_Status",
|
||||||
|
table: "Nominations",
|
||||||
|
columns: new[] { "SeasonId", "Status" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Nominations_SeasonId_Status",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReviewNote",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReviewedAt",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReviewedByTwitchId",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Status",
|
||||||
|
table: "Nominations");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Nominations_SeasonId",
|
||||||
|
table: "Nominations",
|
||||||
|
column: "SeasonId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user