Compare commits

..

44 Commits

Author SHA1 Message Date
AzuTear 23fddbd976 Publish frontend 0.2.3 release notes
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m26s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 11m22s
2026-07-09 23:47:13 +02:00
AzuTear 353eb97563 Sync team role permissions after save 2026-07-09 23:47:07 +02:00
AzuTear 33ba00a682 Harden request validation and admin UX cues
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 58s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m24s
2026-06-30 08:45:28 +02:00
AzuTear 1191abc914 Refresh live demo public content
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m2s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m36s
2026-06-30 08:25:16 +02:00
AzuTear 76ac60d3fd Fix admin demo review and landing regressions
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m34s
2026-06-30 08:18:14 +02:00
AzuTear 0803a0124b Seed 2025 demo archive winners despite empty real season shells
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m2s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m31s
The prior SeedDemoArchivedWinners2025 seed skipped whenever any non-demo
2025 season row existed. Production carries empty 2025/2024 season shells
with no published winners, so the demo archive stayed empty and
overview archiveYears returned []. Re-seed with a precise guard that only
skips when genuine published 2025 winner results exist; idempotent via
ON CONFLICT DO NOTHING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:56:10 +02:00
AzuTear 6b3b0360e7 Add winner archive, host image upload and live demo data
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s
Deliver the demo-ready feature set and seed data so the live site can be
presented end to end:

- Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager
  and public archive surface, backed by AddArchivedWinners migration.
- Host presentation: host image upload and artist name on SiteSettings with
  public image endpoint and supporting migrations.
- Clip submissions: idempotent table-ensure migration plus current-season
  demo clips for review workflows.
- Demo seed data: sponsors, share links and 2025 archived winners, with a
  guarded RemoveDemoSeasons cleanup; all seeds guard against real data.
- EnsureRuntimeSchemaParity migration to align runtime schema defensively.
- Admin/home UI refinements; remove unused team role permissions modal and
  dead share-quick-links code.

All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT)
and skip when real season data is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:47:52 +02:00
AzuTear f323e5a82c Seed current season demo voting data
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 56s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m25s
2026-06-29 18:47:00 +02:00
AzuTear 09dc1de451 Seed live demo award data
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 57s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m27s
2026-06-29 18:41:41 +02:00
AzuTear 049db2e0e0 Verify deployed build metadata
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 3m39s
2026-06-29 18:23:33 +02:00
AzuTear 65c9815baa Guard demo seed migrations
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 1m27s
2026-06-29 18:19:18 +02:00
AzuTear 3794b7f29e Free deploy disk headroom before migrations
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 55s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 1m29s
2026-06-29 18:13:19 +02:00
AzuTear 709a024820 Ensure demo seed schema
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 55s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 54s
2026-06-29 18:10:02 +02:00
AzuTear e497f19652 Ensure site settings seed columns
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 54s
2026-06-29 18:06:34 +02:00
AzuTear c9fd3e47cb Add landing content settings migration
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 57s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 57s
2026-06-29 18:02:05 +02:00
AzuTear 1a74c03621 Restore production migration history
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 57s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 56s
2026-06-29 17:57:47 +02:00
AzuTear 441ef2b850 Update release notes and deploy workspace
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 53s
2026-06-29 17:49:54 +02:00
AzuTear c466348d18 Extract season overview/workflow cards and add sponsor seed data
Splits AdminSeasonsView into AdminSeasonOverviewCard and AdminSeasonWorkflowCard
for cleaner separation of concerns. Adds SeedSponsorsBootstrapper with demo
sponsor SVG assets and extends SeedCatalog with sponsor seed entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:08:56 +02:00
AzuTear 1ab26ec2fd Make AddClipAdminMenuVisible migration idempotent
Rewrites DDL to use IF NOT EXISTS / DROP IF EXISTS and DROP COLUMN IF EXISTS
so the migration can be re-run safely against a DB that already has the schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:07:33 +02:00
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00
AzuTear 4b2c5fa15d Clean up repository hygiene
- Expand .gitignore with editor state, secrets, logs, and archive patterns
- Remove AdminCategoriesViewV2.vue (replaced, no remaining references)
- Exclude accidental AdminContentView duplicate from tsconfig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:31:52 +02:00
AzuTear fc5c13a4fd Add project documentation and update workflow plan
Adds AGENTS.md, DESIGN.md, and docs/* covering architecture,
conventions, decisions, checklists, branching, release process,
and prompts. Updates README and workflow-feedback-plan to reflect
the decoupled GroupName nomination model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:31:13 +02:00
AzuTear 18b61bed52 Improve admin candidate modal UX and add clip menu visibility toggle
- Widen AdminCandidateEditorModal to size lg for better readability
- Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link"
- Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport
- Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 18:39:35 +02:00
AzuTear 494eba5edd Make deploy version metadata runner-safe
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 58s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 1m19s
2026-06-26 22:01:21 +02:00
AzuTear f225addfe8 Add release notes and build version metadata
CI - Build & Verify / Build, Typecheck & Hygiene (push) Has been cancelled
CI - Build & Verify / Deploy to award.noveria.net (push) Has been cancelled
2026-06-26 22:00:39 +02:00
AzuTear 9393d433fa Harden audit privacy and admin modals
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 56s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m5s
2026-06-26 20:47:07 +02:00
AzuTear aeade132f9 Fix team profile auth recovery
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 54s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m5s
2026-06-26 20:06:10 +02:00
AzuTear 47fe59c42a Clean repository artifacts and update README
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 56s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 53s
2026-06-26 18:56:48 +02:00
AzuTear 90795759f3 Refine admin risk workspace
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m3s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m6s
2026-06-26 18:49:01 +02:00
AzuTear ca69cdb0de Fix Gitea pipeline workspace handling
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m7s
2026-06-26 18:37:50 +02:00
AzuTear 3499ad0df5 Use single Gitea CI/CD pipeline
CI - Build & Verify / Build, Typecheck & Hygiene (push) Failing after 11s
CI - Build & Verify / Deploy to award.noveria.net (push) Has been skipped
2026-06-26 18:35:08 +02:00
AzuTear 6a6eb412ae Harden Gitea CI resource usage
CI - Build & Verify / Build, Typecheck & Hygiene (push) Failing after 12s
2026-06-26 18:30:59 +02:00
AzuTear 85b0a3fa42 Add Gitea CI/CD workflow
CI - Build & Verify / Backend (.NET) (push) Successful in 38s
CI - Build & Verify / Frontend (Vue/TS) (push) Successful in 4m53s
CI - Build & Verify / Repository Hygiene (push) Has been cancelled
2026-06-26 18:23:17 +02:00
AzuTear 8b69dfbafb Refine admin workflows and team access 2026-06-26 18:09:29 +02:00
AzuTear b7804e10ea Polish team role and login UI 2026-06-25 20:14:47 +02:00
AzuTear 3bf8b00a83 Seed personal team accounts 2026-06-25 20:06:48 +02:00
AzuTear e2f31d99fd Add footer content columns to production bootstrap 2026-06-25 19:54:17 +02:00
AzuTear 693769e5a8 Add team roles and content management updates 2026-06-25 19:52:46 +02:00
AzuTear 54293f4c45 Embed clips in admin review 2026-06-25 09:59:47 +02:00
AzuTear fd81c968f6 Embed voting clips on landing page 2026-06-25 09:54:22 +02:00
AzuTear f851e500f7 Make demo login gate backend-authoritative 2026-06-25 09:29:25 +02:00
AzuTear fef1d36fe8 Refactor app architecture and clean local artifacts 2026-06-24 23:43:14 +02:00
AzuTear 17134b3b82 Restyle shell and home page with Jayuhime brand; add account and privacy modals
- Replaces amber/Manrope shell with sticky purple nav (Outfit + Fredoka)
- Adds account modal: profile info, Datenschutzerklärung, DSGVO Art. 17 delete flow
- Home: new hero with animated blobs/stars/countdown, inline vote picker, clip submit form, show-reminder, FAQ
- Switches base palette to purple (#f4eefb / #3f3556) and adds keyframe animations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:15:53 +02:00
AzuTear 30fb931bd3 Refresh home view with updated hero and steps 2026-06-22 18:06:47 +02:00
490 changed files with 116132 additions and 8542 deletions
+330
View File
@@ -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
View File
@@ -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
+256
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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";
}
+3
View File
@@ -0,0 +1,3 @@
namespace Backend.Common;
public sealed record RequestMetadata(string ClientIp, string UserAgent);
+16
View File
@@ -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;
}
}
+263
View File
@@ -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;
}
}
+8
View File
@@ -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);
-115
View File
@@ -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);
+204
View File
@@ -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);
+66
View File
@@ -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);
+33 -1
View File
@@ -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);
+53
View File
@@ -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);
-87
View File
@@ -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);
+31
View File
@@ -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);
+182 -1
View File
@@ -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 });
});
} }
} }
+18 -2
View File
@@ -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");
""");
}
+184
View File
@@ -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),
];
}
-125
View File
@@ -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 });
}
}
-24
View File
@@ -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");
""");
}
+186
View File
@@ -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);
}
+2
View File
@@ -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; }
} }
+13
View File
@@ -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; }
}
+2
View File
@@ -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;
+9
View File
@@ -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";
} }
+2
View File
@@ -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
View File
@@ -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; }
} }
+26 -2
View File
@@ -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; }
} }
+1
View File
@@ -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; }
+6
View File
@@ -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; } = [];
} }
+24
View File
@@ -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; }
}
+77
View File
@@ -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.";
}
+17
View File
@@ -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; }
}
+14
View File
@@ -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; } = [];
}
+22
View File
@@ -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; }
}
+10
View File
@@ -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; }
}
+249
View File
@@ -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);
}
}
+23
View File
@@ -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;
}
}
+322
View File
@@ -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;
}
}
+382
View File
@@ -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,
};
}
}
+124
View File
@@ -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
+630
View File
@@ -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,
});
}
}
+201
View File
@@ -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));
}
}
+62
View File
@@ -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;
}
}
+60
View File
@@ -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);
}
}
+133
View File
@@ -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);
}
+181
View File
@@ -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 });
}
}
+279
View File
@@ -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;
}
}
+59
View File
@@ -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;
}
}
+387
View File
@@ -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));
}
}
+178
View File
@@ -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));
}
}
+50
View File
@@ -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